feat: centralize application framework primitives
This commit is contained in:
@@ -10,6 +10,8 @@ import {
|
||||
} from "../middleware.ts";
|
||||
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
|
||||
import type { AuthSessionVerificationHandler } from "../types.ts";
|
||||
import { completeAuth, startAuth, type OAuthProvider } from "@wrnexus/oauth";
|
||||
import { assignDefaultAuthzRoles } from "@wrnexus/authz";
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
@@ -50,6 +52,8 @@ export interface AuthHttpOptions {
|
||||
schemas?: AuthSchemaOverrides | AuthSchemaSet;
|
||||
passkey?: AuthPasskeyHttpOptions;
|
||||
onSessionVerification?: AuthSessionVerificationHandler;
|
||||
oauth?: Record<string, OAuthProvider>;
|
||||
oauthMfaPath?: string;
|
||||
}
|
||||
|
||||
export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
@@ -59,6 +63,17 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
const onSignedOut = engine.onSignedOut;
|
||||
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
|
||||
|
||||
function oauthProvider(ctx: Context): OAuthProvider | undefined {
|
||||
return options.oauth?.[String(ctx.params.provider ?? "").toLowerCase()];
|
||||
}
|
||||
|
||||
function oauthRedirectUri(ctx: Context, provider: OAuthProvider): string {
|
||||
return new URL(
|
||||
`/api/auth/oauth/${encodeURIComponent(provider.name)}/callback`,
|
||||
options.baseUrl ?? ctx.url.origin,
|
||||
).toString();
|
||||
}
|
||||
|
||||
function signupRedirect(ctx: Context, value: string | undefined, fallback: string): Response {
|
||||
const path = safeAuthReturnTo(value, ctx.url.origin) ?? fallback;
|
||||
return Response.redirect(new URL(path, ctx.url), 303);
|
||||
@@ -80,6 +95,71 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
}
|
||||
|
||||
return {
|
||||
async oauthProviders(ctx: Context): Promise<Response> {
|
||||
return json({
|
||||
ok: true,
|
||||
providers: Object.values(options.oauth ?? {}).map((provider) => ({
|
||||
id: provider.name,
|
||||
name: provider.name,
|
||||
label: `Continue with ${provider.name.charAt(0).toUpperCase()}${provider.name.slice(1)}`,
|
||||
href: `/api/auth/oauth/${encodeURIComponent(provider.name)}?returnTo=${encodeURIComponent(
|
||||
safeAuthReturnTo(ctx.url.searchParams.get("returnTo") ?? undefined, ctx.url.origin) ??
|
||||
"/",
|
||||
)}`,
|
||||
})),
|
||||
});
|
||||
},
|
||||
async startOAuth(ctx: Context): Promise<Response> {
|
||||
const provider = oauthProvider(ctx);
|
||||
if (!provider) return json({ ok: false, error: "OAuth provider not configured" }, 404);
|
||||
const returnTo =
|
||||
safeAuthReturnTo(ctx.url.searchParams.get("returnTo") ?? undefined, ctx.url.origin) ?? "/";
|
||||
const started = await startAuth(provider, { redirectUri: oauthRedirectUri(ctx, provider) });
|
||||
ctx.cookies.transaction(`wrnexus.oauth.${provider.name}`).set({
|
||||
state: started.state,
|
||||
verifier: started.verifier,
|
||||
returnTo,
|
||||
});
|
||||
return Response.redirect(started.url, 302);
|
||||
},
|
||||
|
||||
async completeOAuth(ctx: Context): Promise<Response> {
|
||||
const provider = oauthProvider(ctx);
|
||||
if (!provider) return json({ ok: false, error: "OAuth provider not configured" }, 404);
|
||||
const transaction = ctx.cookies
|
||||
.transaction<{
|
||||
state: string;
|
||||
verifier: string;
|
||||
returnTo: string;
|
||||
}>(`wrnexus.oauth.${provider.name}`)
|
||||
.consume();
|
||||
const state = ctx.url.searchParams.get("state");
|
||||
const code = ctx.url.searchParams.get("code");
|
||||
if (!transaction || !state || transaction.state !== state || !code) {
|
||||
return json({ ok: false, error: "OAuth transaction is invalid or expired" }, 400);
|
||||
}
|
||||
const completed = await completeAuth(provider, {
|
||||
code,
|
||||
verifier: transaction.verifier,
|
||||
redirectUri: oauthRedirectUri(ctx, provider),
|
||||
});
|
||||
const result = await engine.loginWithOAuth(
|
||||
provider.name,
|
||||
completed.profile,
|
||||
completed.tokens,
|
||||
);
|
||||
if (result.code === "mfa-required" && result.mfaToken) {
|
||||
ctx.cookies.transaction("wrnexus.auth.mfa", { sameSite: "Lax", maxAge: 300 }).set({
|
||||
mfaToken: result.mfaToken,
|
||||
returnTo: transaction.returnTo,
|
||||
});
|
||||
return Response.redirect(new URL(options.oauthMfaPath ?? "/two-factor", ctx.url), 303);
|
||||
}
|
||||
if (!result.ok || !result.session || !result.user) return json(result, 401);
|
||||
establishAuthSession(ctx, result.session, result.user);
|
||||
if (onSignedIn) return onSignedIn(ctx, transaction.returnTo);
|
||||
return Response.redirect(new URL(transaction.returnTo, ctx.url), 303);
|
||||
},
|
||||
async verifySession(ctx: Context): Promise<Response> {
|
||||
const user = getAuthUser(ctx);
|
||||
if (options.onSessionVerification) {
|
||||
@@ -110,6 +190,8 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
});
|
||||
if (!result.ok || !result.user) return json(result, 400);
|
||||
|
||||
await assignDefaultAuthzRoles(result.user.id, "signup");
|
||||
|
||||
const action = await onSuccessfulSignUp?.(ctx, result.user);
|
||||
if (action instanceof Response) return action;
|
||||
if (action?.autoSignIn) {
|
||||
@@ -230,6 +312,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
password: text(input.password) || undefined,
|
||||
displayName: text(input.displayName) || undefined,
|
||||
});
|
||||
if (result.ok && result.user) await assignDefaultAuthzRoles(result.user.id, "invitation");
|
||||
return json(result, result.ok ? 200 : 400);
|
||||
},
|
||||
|
||||
@@ -403,12 +486,18 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
const validation = await parseBody(schemas.mfaComplete, ctx.req);
|
||||
if (!validation.ok) return validation.response;
|
||||
const input = validation.value;
|
||||
const continuation = ctx.cookies
|
||||
.transaction<{ mfaToken: string; returnTo?: string }>("wrnexus.auth.mfa", {
|
||||
sameSite: "Lax",
|
||||
maxAge: 300,
|
||||
})
|
||||
.consume();
|
||||
const methodValue = text(input.method);
|
||||
const method = ["totp", "recovery-code", "email-otp", "sms-otp"].includes(methodValue)
|
||||
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
|
||||
: "totp";
|
||||
const result = await engine.completeMfa({
|
||||
mfaToken: text(input.mfaToken),
|
||||
mfaToken: text(input.mfaToken) || continuation?.mfaToken || "",
|
||||
method,
|
||||
code: text(input.code),
|
||||
challengeId: text(input.challengeId) || undefined,
|
||||
@@ -421,7 +510,10 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
if (!result.ok || !result.session || !result.user) return json(result, 400);
|
||||
establishAuthSession(ctx, result.session, result.user);
|
||||
if (onSignedIn) {
|
||||
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
|
||||
return onSignedIn(
|
||||
ctx,
|
||||
safeAuthReturnTo(text(input.returnTo) || continuation?.returnTo, ctx.url.origin),
|
||||
);
|
||||
}
|
||||
return json(result);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user