@facilis.dev/auth 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/dist/api-clients.d.ts +71 -0
  4. package/dist/api-clients.js +186 -0
  5. package/dist/crypto.d.ts +31 -0
  6. package/dist/crypto.js +76 -0
  7. package/dist/email-verification.d.ts +45 -0
  8. package/dist/email-verification.js +72 -0
  9. package/dist/email.d.ts +7 -0
  10. package/dist/email.js +9 -0
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.js +12 -0
  13. package/dist/password.d.ts +7 -0
  14. package/dist/password.js +54 -0
  15. package/dist/roles.d.ts +40 -0
  16. package/dist/roles.js +46 -0
  17. package/dist/seed.d.ts +28 -0
  18. package/dist/seed.js +115 -0
  19. package/dist/session.d.ts +66 -0
  20. package/dist/session.js +161 -0
  21. package/dist/sso/contracts.d.ts +52 -0
  22. package/dist/sso/contracts.js +8 -0
  23. package/dist/sso/errors.d.ts +22 -0
  24. package/dist/sso/errors.js +45 -0
  25. package/dist/sso/identity.d.ts +41 -0
  26. package/dist/sso/identity.js +162 -0
  27. package/dist/sso/index.d.ts +27 -0
  28. package/dist/sso/index.js +16 -0
  29. package/dist/sso/login.d.ts +60 -0
  30. package/dist/sso/login.js +209 -0
  31. package/dist/sso/oauth2.d.ts +52 -0
  32. package/dist/sso/oauth2.js +180 -0
  33. package/dist/sso/oidc.d.ts +60 -0
  34. package/dist/sso/oidc.js +201 -0
  35. package/dist/sso/paths.d.ts +14 -0
  36. package/dist/sso/paths.js +17 -0
  37. package/dist/sso/pkce.d.ts +6 -0
  38. package/dist/sso/pkce.js +21 -0
  39. package/dist/sso/providers.d.ts +100 -0
  40. package/dist/sso/providers.js +70 -0
  41. package/dist/sso/saml.d.ts +44 -0
  42. package/dist/sso/saml.js +159 -0
  43. package/dist/sso/state.d.ts +34 -0
  44. package/dist/sso/state.js +91 -0
  45. package/dist/sso/tables.d.ts +306 -0
  46. package/dist/sso/tables.js +59 -0
  47. package/dist/tables.d.ts +769 -0
  48. package/dist/tables.js +148 -0
  49. package/package.json +45 -4
  50. package/src/api-clients.ts +261 -0
  51. package/src/crypto.ts +101 -0
  52. package/src/email-verification.ts +107 -0
  53. package/src/email.ts +9 -0
  54. package/src/index.ts +63 -0
  55. package/src/password.ts +74 -0
  56. package/src/roles.ts +77 -0
  57. package/src/seed.ts +146 -0
  58. package/src/session.ts +209 -0
  59. package/src/sso/contracts.ts +71 -0
  60. package/src/sso/errors.ts +53 -0
  61. package/src/sso/identity.ts +220 -0
  62. package/src/sso/index.ts +105 -0
  63. package/src/sso/login.ts +296 -0
  64. package/src/sso/oauth2.ts +235 -0
  65. package/src/sso/oidc.ts +285 -0
  66. package/src/sso/paths.ts +21 -0
  67. package/src/sso/pkce.ts +30 -0
  68. package/src/sso/providers.ts +176 -0
  69. package/src/sso/saml.ts +222 -0
  70. package/src/sso/state.ts +114 -0
  71. package/src/sso/tables.ts +77 -0
  72. package/src/tables.ts +214 -0
  73. package/src/test-support.ts +149 -0
@@ -0,0 +1,220 @@
1
+ import { and, eq, sql } from "drizzle-orm";
2
+
3
+ import type { PolicyScopedDatabase } from "@facilis.dev/db";
4
+
5
+ import type { IdentityClaims, SsoAudit, SystemRunner } from "./contracts.ts";
6
+ import { SsoError } from "./errors.ts";
7
+ import { authIdentities } from "./tables.ts";
8
+ import { normalizeEmail } from "../email.ts";
9
+ import { users, userRoles } from "../tables.ts";
10
+
11
+ /**
12
+ * Account linking and JIT provisioning (D-120).
13
+ *
14
+ * The identity key is `(provider, subject)`: a changed email never breaks an existing link.
15
+ * Without a link, the asserted email must be verified before it can match a user, and a user is
16
+ * only created when the provider's `autoProvision` allows it. Every read/write happens inside the
17
+ * caller's audited `ctx.system` elevation — never on a raw client (D-099).
18
+ *
19
+ * Races are handled with the unique keys the schema already has: `(provider, subject)` for the
20
+ * identity and `users.email` for the user, both `ON CONFLICT DO NOTHING` followed by a re-read.
21
+ */
22
+
23
+ export const IDENTITY_TABLES = ["auth_identities", "users", "user_roles"] as const;
24
+
25
+ const IDENTITY_ELEVATION = {
26
+ actor: "system:auth:sso" as const,
27
+ operation: "auth:sso:identity:resolve",
28
+ reason: "Linking or provisioning the user happens before the SSO session exists.",
29
+ tables: IDENTITY_TABLES,
30
+ };
31
+
32
+ export interface IdentityProviderPolicy {
33
+ readonly id: string;
34
+ readonly autoProvision: boolean;
35
+ readonly defaultRole?: string;
36
+ }
37
+
38
+ export interface ResolveIdentityOptions {
39
+ /** `config.auth.defaultRole`, used when the provider has no `defaultRole`. */
40
+ readonly defaultRole: string;
41
+ /**
42
+ * `config.auth.verification.enabled` (D-156): with email verification on, a pending password
43
+ * account is claimed (the IdP just proved the mailbox). With it off, the account was never
44
+ * meant to be verified, so the link is refused instead of silently resetting its password.
45
+ */
46
+ readonly claimUnverifiedPasswordAccount: boolean;
47
+ readonly audit: SsoAudit;
48
+ }
49
+
50
+ export type IdentityOutcome = "existing" | "linked" | "provisioned";
51
+
52
+ export interface IdentityResolution {
53
+ readonly userId: string;
54
+ readonly outcome: IdentityOutcome;
55
+ }
56
+
57
+ function roleFor(provider: IdentityProviderPolicy, options: ResolveIdentityOptions): string {
58
+ return provider.defaultRole ?? options.defaultRole;
59
+ }
60
+
61
+ async function findIdentityUserId(
62
+ db: PolicyScopedDatabase,
63
+ provider: string,
64
+ subject: string,
65
+ ): Promise<string | null> {
66
+ const rows = await db
67
+ .select({ userId: authIdentities.userId })
68
+ .from(authIdentities)
69
+ .where(and(eq(authIdentities.provider, provider), eq(authIdentities.subject, subject)))
70
+ .limit(1);
71
+ return rows[0]?.userId ?? null;
72
+ }
73
+
74
+ async function findUserByEmail(
75
+ db: PolicyScopedDatabase,
76
+ email: string,
77
+ ): Promise<{ id: string; passwordHash: string | null; emailVerifiedAt: Date | null } | null> {
78
+ const rows = await db
79
+ .select({
80
+ id: users.id,
81
+ passwordHash: users.passwordHash,
82
+ emailVerifiedAt: users.emailVerifiedAt,
83
+ })
84
+ .from(users)
85
+ .where(sql`lower(${users.email}) = ${normalizeEmail(email)}`)
86
+ .limit(1);
87
+ return rows[0] ?? null;
88
+ }
89
+
90
+ /**
91
+ * A service account exists only to back an API client: it must never complete an interactive
92
+ * login. The generic `sso_account_not_found` is deliberate — the flow must not become a probe
93
+ * for which addresses belong to a service identity.
94
+ */
95
+ async function assertLoginable(db: PolicyScopedDatabase, userId: string): Promise<void> {
96
+ const rows = await db
97
+ .select({ isService: users.isService })
98
+ .from(users)
99
+ .where(eq(users.id, userId))
100
+ .limit(1);
101
+ if (rows[0]?.isService === true) throw new SsoError("sso_account_not_found");
102
+ }
103
+
104
+ async function touchIdentity(
105
+ db: PolicyScopedDatabase,
106
+ claims: IdentityClaims,
107
+ now: Date,
108
+ ): Promise<void> {
109
+ await db
110
+ .update(authIdentities)
111
+ .set({ email: normalizeEmail(claims.email), lastLoginAt: now })
112
+ .where(
113
+ and(eq(authIdentities.provider, claims.provider), eq(authIdentities.subject, claims.subject)),
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Resolves the Facilis user for a verified identity.
119
+ *
120
+ * Throws `sso_email_unverified` for an unverified email that has no existing link and
121
+ * `sso_account_not_found` when provisioning is disabled; both are safe to redirect.
122
+ */
123
+ export async function resolveIdentity(
124
+ system: SystemRunner,
125
+ claims: IdentityClaims,
126
+ provider: IdentityProviderPolicy,
127
+ options: ResolveIdentityOptions,
128
+ now: Date = new Date(),
129
+ ): Promise<IdentityResolution> {
130
+ return system(IDENTITY_ELEVATION, async (db) => {
131
+ const linked = await findIdentityUserId(db, claims.provider, claims.subject);
132
+ if (linked !== null) {
133
+ await assertLoginable(db, linked);
134
+ await touchIdentity(db, claims, now);
135
+ return { userId: linked, outcome: "existing" };
136
+ }
137
+
138
+ // An unverified email must never link to (or provision) an account: that is account takeover.
139
+ if (!claims.emailVerified) throw new SsoError("sso_email_unverified");
140
+
141
+ const existingUser = await findUserByEmail(db, claims.email);
142
+ if (existingUser !== null) {
143
+ await assertLoginable(db, existingUser.id);
144
+ // Account pre-hijacking (SEC-02): a pending password account was registered by someone
145
+ // who never proved they own the address, and with email verification enabled it cannot
146
+ // even log in. The verified IdP email IS that proof now, so the identity claims the
147
+ // account and the placeholder password is removed before linking (D-156): whoever
148
+ // registered the address first loses it, the mailbox owner keeps it. A verified password
149
+ // account belongs to the same proven mailbox and links without clearing anything.
150
+ const claimed = existingUser.passwordHash !== null && existingUser.emailVerifiedAt === null;
151
+ if (claimed) {
152
+ // With verification disabled the account was never meant to prove its mailbox: refuse
153
+ // rather than silently reset a password its owner may still use (D-156).
154
+ if (!options.claimUnverifiedPasswordAccount) throw new SsoError("sso_email_conflict");
155
+ await db
156
+ .update(users)
157
+ .set({ passwordHash: null, emailVerifiedAt: now })
158
+ .where(eq(users.id, existingUser.id));
159
+ }
160
+ const inserted = await db
161
+ .insert(authIdentities)
162
+ .values({
163
+ provider: claims.provider,
164
+ subject: claims.subject,
165
+ userId: existingUser.id,
166
+ email: normalizeEmail(claims.email),
167
+ })
168
+ .onConflictDoNothing()
169
+ .returning({ userId: authIdentities.userId });
170
+ const raced =
171
+ inserted[0]?.userId ?? (await findIdentityUserId(db, claims.provider, claims.subject));
172
+ if (raced === null) throw new SsoError("sso_internal_error", "identity link race");
173
+ await touchIdentity(db, claims, now);
174
+ options.audit({
175
+ operation: "auth:sso:link",
176
+ outcome: "succeeded",
177
+ tables: claimed ? ["auth_identities", "users"] : ["auth_identities"],
178
+ details: {
179
+ provider: claims.provider,
180
+ ...(claimed ? { claimed: "unverified_password" } : {}),
181
+ },
182
+ });
183
+ return { userId: raced, outcome: "linked" };
184
+ }
185
+
186
+ if (!provider.autoProvision) throw new SsoError("sso_account_not_found");
187
+
188
+ const role = roleFor(provider, options);
189
+ const created = await db
190
+ .insert(users)
191
+ .values({ email: normalizeEmail(claims.email), passwordHash: null })
192
+ .onConflictDoNothing()
193
+ .returning({ id: users.id });
194
+ const userId = created[0]?.id ?? (await findUserByEmail(db, claims.email))?.id ?? null;
195
+ if (userId === null) throw new SsoError("sso_internal_error", "user provision race");
196
+
197
+ await db.insert(userRoles).values({ userId, role }).onConflictDoNothing();
198
+ const identity = await db
199
+ .insert(authIdentities)
200
+ .values({
201
+ provider: claims.provider,
202
+ subject: claims.subject,
203
+ userId,
204
+ email: normalizeEmail(claims.email),
205
+ })
206
+ .onConflictDoNothing()
207
+ .returning({ userId: authIdentities.userId });
208
+ const resolved =
209
+ identity[0]?.userId ?? (await findIdentityUserId(db, claims.provider, claims.subject));
210
+ if (resolved === null) throw new SsoError("sso_internal_error", "identity provision race");
211
+
212
+ options.audit({
213
+ operation: "auth:sso:provision",
214
+ outcome: "succeeded",
215
+ tables: ["users", "user_roles", "auth_identities"],
216
+ details: { provider: claims.provider, role },
217
+ });
218
+ return { userId: resolved, outcome: "provisioned" };
219
+ });
220
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * SSO (D-120): OIDC, generic OAuth2 and SAML as framework auth.
3
+ *
4
+ * Root re-exports only; the implementation is split by concern under this directory.
5
+ */
6
+
7
+ export { authIdentities, authSsoStates } from "./tables.ts";
8
+ export type {
9
+ AuthIdentityRow,
10
+ AuthSsoStateRow,
11
+ NewAuthIdentityRow,
12
+ NewAuthSsoStateRow,
13
+ } from "./tables.ts";
14
+ export {
15
+ SSO_ERROR_CODES,
16
+ failureRedirectPath,
17
+ isSsoError,
18
+ SsoError,
19
+ ssoErrorCode,
20
+ } from "./errors.ts";
21
+ export type { SsoErrorCode } from "./errors.ts";
22
+ export {
23
+ isProviderConfigured,
24
+ missingCredentialEnv,
25
+ publicProviderList,
26
+ resolveProvider,
27
+ SSO_NAMESPACE,
28
+ } from "./providers.ts";
29
+ export type {
30
+ Oauth2Mapping,
31
+ Oauth2ProviderConfig,
32
+ OidcProviderConfig,
33
+ PublicSsoProvider,
34
+ ResolvedSsoProvider,
35
+ SamlProviderConfig,
36
+ SsoConfigLike,
37
+ SsoProviderBase,
38
+ SsoProviderConfig,
39
+ SsoProviderKind,
40
+ SsoTokenAuth,
41
+ } from "./providers.ts";
42
+ export { consumeSsoState, createSsoState, MAX_REDIRECT_TO, normalizeRedirectTo } from "./state.ts";
43
+ export type { CreateSsoStateInput } from "./state.ts";
44
+ export {
45
+ buildOidcAuthorizeUrl,
46
+ clearOidcDiscoveryCache,
47
+ discoverOidc,
48
+ exchangeOidcCode,
49
+ OIDC_DISCOVERY_TTL_MS,
50
+ resolveOidcIdentity,
51
+ verifyOidcIdToken,
52
+ } from "./oidc.ts";
53
+ export { createPkcePair } from "./pkce.ts";
54
+ export type {
55
+ OidcAuthorizeInput,
56
+ OidcClaims,
57
+ OidcDeps,
58
+ OidcDiscovery,
59
+ OidcTokenInput,
60
+ } from "./oidc.ts";
61
+ export type { PkcePair } from "./pkce.ts";
62
+ export {
63
+ buildOauth2AuthorizeUrl,
64
+ exchangeOauth2Code,
65
+ readDottedPath,
66
+ readVerifiedClaim,
67
+ requestToken,
68
+ resolveOauth2Identity,
69
+ resolveOauth2IdentityFromCode,
70
+ } from "./oauth2.ts";
71
+ export type {
72
+ Oauth2Identity,
73
+ Oauth2TokenInput,
74
+ RequestTokenInput,
75
+ TokenClientConfig,
76
+ TokenPayload,
77
+ } from "./oauth2.ts";
78
+ export { buildAuthnRequestXml, buildSamlRedirect, validateSamlResponse } from "./saml.ts";
79
+ export type {
80
+ BuiltSamlRequest,
81
+ SamlProfileLike,
82
+ SamlValidator,
83
+ SamlValidatorFactory,
84
+ ValidateSamlInput,
85
+ } from "./saml.ts";
86
+ export { IDENTITY_TABLES, resolveIdentity } from "./identity.ts";
87
+ export type {
88
+ IdentityOutcome,
89
+ IdentityProviderPolicy,
90
+ IdentityResolution,
91
+ ResolveIdentityOptions,
92
+ } from "./identity.ts";
93
+ export { completeLogin, startLogin } from "./login.ts";
94
+ export { DEFAULT_POST_LOGIN_PATH, postLoginPath, ssoCallbackUrl } from "./paths.ts";
95
+ export type { CompleteLoginInput, CompleteLoginResult, SsoFlowDeps } from "./login.ts";
96
+ export type {
97
+ FetchInit,
98
+ FetchLike,
99
+ FetchResponse,
100
+ IdentityClaims,
101
+ SsoAudit,
102
+ SsoAuditInput,
103
+ SystemElevationOptions,
104
+ SystemRunner,
105
+ } from "./contracts.ts";
@@ -0,0 +1,296 @@
1
+ import { randomToken } from "../crypto.ts";
2
+ import type { FetchLike, IdentityClaims, SsoAudit, SystemRunner } from "./contracts.ts";
3
+ import { isSsoError, SsoError, ssoErrorCode } from "./errors.ts";
4
+ import { resolveIdentity, type IdentityResolution } from "./identity.ts";
5
+ import {
6
+ buildOidcAuthorizeUrl,
7
+ discoverOidc,
8
+ exchangeOidcCode,
9
+ resolveOidcIdentity,
10
+ type OidcDeps,
11
+ } from "./oidc.ts";
12
+ import { buildOauth2AuthorizeUrl, resolveOauth2IdentityFromCode } from "./oauth2.ts";
13
+ import { createPkcePair } from "./pkce.ts";
14
+ import { postLoginPath, ssoCallbackUrl } from "./paths.ts";
15
+ import {
16
+ identityPolicy,
17
+ missingCredentialEnv,
18
+ resolveProvider,
19
+ type ResolvedSsoProvider,
20
+ type SsoConfigLike,
21
+ type SsoProviderConfig,
22
+ } from "./providers.ts";
23
+ import { buildSamlRedirect, validateSamlResponse, type SamlValidatorFactory } from "./saml.ts";
24
+ import { consumeSsoState, createSsoState, normalizeRedirectTo } from "./state.ts";
25
+ import type { AuthSsoStateRow } from "./tables.ts";
26
+ /**
27
+ * The two ends of an SSO login (D-120): start builds the IdP redirect and stores the single-use
28
+ * attempt; complete consumes that attempt, resolves the identity and returns the user.
29
+ *
30
+ * The route owns the HTTP envelope (302, cookie, failure redirect); this module owns the protocol
31
+ * and never exposes an internal message. Every failure is an {@link SsoError} whose code is safe
32
+ * to append to `failureRedirect`.
33
+ */
34
+ export interface SsoFlowDeps {
35
+ /** `config.auth.sso` after defaults. */
36
+ readonly sso: SsoConfigLike;
37
+ /** `config.auth.defaultRole`: the role a provisioned user gets without a provider override. */
38
+ readonly defaultRole: string;
39
+ /** `config.auth.verification.enabled`: allows the SSO identity to claim a pending account. */
40
+ readonly verificationEnabled: boolean;
41
+ /** Deployment origin (`FACILIS_PUBLIC_ORIGIN` or the request origin), no trailing slash. */
42
+ readonly publicOrigin: string;
43
+ readonly system: SystemRunner;
44
+ readonly audit: SsoAudit;
45
+ readonly fetch?: FetchLike;
46
+ readonly now?: () => number;
47
+ readonly environment?: Readonly<Record<string, string | undefined>>;
48
+ /** Diagnostic line for an unusable provider; must never receive a secret value. */
49
+ readonly warn?: (message: string, data: Record<string, unknown>) => void;
50
+ /** Test seam for discovery/JWKS; defaults to the flow's fetch/now. */
51
+ readonly oidc?: OidcDeps;
52
+ /** Test seam for the lazily imported SAML validator. */
53
+ readonly samlValidatorFactory?: SamlValidatorFactory;
54
+ }
55
+ function oidcDeps(deps: SsoFlowDeps): OidcDeps {
56
+ if (deps.oidc !== undefined) return deps.oidc;
57
+ return {
58
+ ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
59
+ ...(deps.now === undefined ? {} : { now: deps.now }),
60
+ };
61
+ }
62
+ function resolve(deps: SsoFlowDeps, providerId: string): ResolvedSsoProvider {
63
+ try {
64
+ return resolveProvider(deps.sso, providerId, deps.environment ?? process.env);
65
+ } catch (error) {
66
+ if (isSsoError(error) && error.code === "sso_provider_unavailable") {
67
+ const provider = deps.sso.providers.find((candidate) => candidate.id === providerId);
68
+ const envName = provider === undefined ? undefined : missingCredentialEnv(provider);
69
+ // The warning names the provider and the ENV VAR, never its value (D-120).
70
+ deps.warn?.("sso provider is not configured", {
71
+ provider: providerId,
72
+ ...(envName === undefined ? {} : { env: envName }),
73
+ });
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+ function nowOf(deps: SsoFlowDeps): number {
79
+ return deps.now?.() ?? Date.now();
80
+ }
81
+ function tokenClient(config: SsoProviderConfig, secret: string | undefined) {
82
+ if (config.kind === "saml") throw new SsoError("sso_state_invalid");
83
+ return {
84
+ clientId: config.clientId,
85
+ tokenAuth: config.tokenAuth,
86
+ ...(secret === undefined ? {} : { clientSecret: secret }),
87
+ };
88
+ }
89
+ async function startRedirect(
90
+ deps: SsoFlowDeps,
91
+ input: { readonly providerId: string; readonly redirectTo: string | null },
92
+ provider: ResolvedSsoProvider,
93
+ ): Promise<string> {
94
+ const config = provider.config;
95
+ const redirectTo = normalizeRedirectTo(input.redirectTo);
96
+ const now = nowOf(deps);
97
+ const redirectUri = ssoCallbackUrl(deps.publicOrigin, config.id);
98
+
99
+ if (config.kind === "saml") {
100
+ const state = randomToken(32);
101
+ const built = await buildSamlRedirect(config, redirectUri, state, now);
102
+ await createSsoState(
103
+ deps.system,
104
+ {
105
+ provider: config.id,
106
+ ttlMs: deps.sso.stateTtlMs,
107
+ redirectTo,
108
+ requestId: built.requestId,
109
+ state,
110
+ },
111
+ now,
112
+ );
113
+ return built.redirectUrl;
114
+ }
115
+
116
+ if (config.kind === "oidc") {
117
+ const discovery = await discoverOidc(config, oidcDeps(deps));
118
+ const pkce = await createPkcePair();
119
+ const nonce = randomToken(32);
120
+ const state = await createSsoState(
121
+ deps.system,
122
+ {
123
+ provider: config.id,
124
+ ttlMs: deps.sso.stateTtlMs,
125
+ redirectTo,
126
+ nonce,
127
+ codeVerifier: pkce.verifier,
128
+ },
129
+ now,
130
+ );
131
+ return buildOidcAuthorizeUrl(config, discovery, {
132
+ redirectUri,
133
+ state: state.state,
134
+ nonce,
135
+ codeChallenge: pkce.challenge,
136
+ });
137
+ }
138
+
139
+ const pkce = await createPkcePair();
140
+ const state = await createSsoState(
141
+ deps.system,
142
+ {
143
+ provider: config.id,
144
+ ttlMs: deps.sso.stateTtlMs,
145
+ redirectTo,
146
+ codeVerifier: pkce.verifier,
147
+ },
148
+ now,
149
+ );
150
+ return buildOauth2AuthorizeUrl(config, redirectUri, state.state, pkce.challenge);
151
+ }
152
+ /** Starts a login and returns the IdP URL; the attempt is stored as one single-use row. */
153
+ export async function startLogin(
154
+ deps: SsoFlowDeps,
155
+ input: { readonly providerId: string; readonly redirectTo: string | null },
156
+ ): Promise<{ readonly redirectUrl: string }> {
157
+ try {
158
+ const provider = resolve(deps, input.providerId);
159
+ const redirectUrl = await startRedirect(deps, input, provider);
160
+ deps.audit({
161
+ operation: "auth:sso:start",
162
+ outcome: "succeeded",
163
+ details: { provider: input.providerId },
164
+ });
165
+ return { redirectUrl };
166
+ } catch (error) {
167
+ deps.audit({
168
+ operation: "auth:sso:start",
169
+ outcome: "failed",
170
+ errorCode: ssoErrorCode(error),
171
+ details: { provider: input.providerId },
172
+ });
173
+ throw error;
174
+ }
175
+ }
176
+ export type CompleteLoginInput =
177
+ | { readonly method: "GET"; readonly code: string; readonly state: string }
178
+ | { readonly method: "POST"; readonly samlResponse: string; readonly relayState: string };
179
+ export interface CompleteLoginResult {
180
+ readonly userId: string;
181
+ readonly redirectTo: string;
182
+ readonly resolution: IdentityResolution;
183
+ }
184
+ async function claimsFromCode(
185
+ deps: SsoFlowDeps,
186
+ provider: ResolvedSsoProvider,
187
+ code: string,
188
+ state: AuthSsoStateRow,
189
+ ): Promise<IdentityClaims> {
190
+ const config = provider.config;
191
+ if (state.provider !== config.id) throw new SsoError("sso_state_invalid");
192
+ if (config.kind === "oidc") {
193
+ if (state.nonce === null || state.codeVerifier === null)
194
+ throw new SsoError("sso_state_invalid");
195
+ const discovery = await discoverOidc(config, oidcDeps(deps));
196
+ const token = await exchangeOidcCode(tokenClient(config, provider.clientSecret), discovery, {
197
+ code,
198
+ redirectUri: ssoCallbackUrl(deps.publicOrigin, config.id),
199
+ codeVerifier: state.codeVerifier,
200
+ ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
201
+ });
202
+ return resolveOidcIdentity(config, discovery, token, state.nonce, oidcDeps(deps));
203
+ }
204
+ if (config.kind === "oauth2") {
205
+ if (state.codeVerifier === null) throw new SsoError("sso_state_invalid");
206
+ const identity = await resolveOauth2IdentityFromCode(
207
+ tokenClient(config, provider.clientSecret),
208
+ config,
209
+ {
210
+ code,
211
+ redirectUri: ssoCallbackUrl(deps.publicOrigin, config.id),
212
+ codeVerifier: state.codeVerifier,
213
+ ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
214
+ },
215
+ );
216
+ return { provider: config.id, ...identity };
217
+ }
218
+ throw new SsoError("sso_state_invalid");
219
+ }
220
+ async function completeGet(
221
+ deps: SsoFlowDeps,
222
+ provider: ResolvedSsoProvider,
223
+ input: Extract<CompleteLoginInput, { method: "GET" }>,
224
+ ): Promise<CompleteLoginResult> {
225
+ const state = await consumeSsoState(deps.system, input.state, nowOf(deps));
226
+ const claims = await claimsFromCode(deps, provider, input.code, state);
227
+ const resolution = await resolveIdentity(deps.system, claims, identityPolicy(provider.config), {
228
+ defaultRole: deps.defaultRole,
229
+ claimUnverifiedPasswordAccount: deps.verificationEnabled,
230
+ audit: deps.audit,
231
+ });
232
+ return { userId: resolution.userId, redirectTo: postLoginPath(state), resolution };
233
+ }
234
+ async function completePost(
235
+ deps: SsoFlowDeps,
236
+ provider: ResolvedSsoProvider,
237
+ input: Extract<CompleteLoginInput, { method: "POST" }>,
238
+ ): Promise<CompleteLoginResult> {
239
+ const config = provider.config;
240
+ if (config.kind !== "saml") throw new SsoError("sso_state_invalid");
241
+ const state = await consumeSsoState(deps.system, input.relayState, nowOf(deps));
242
+ if (state.provider !== config.id) throw new SsoError("sso_state_invalid");
243
+ const claims = await validateSamlResponse({
244
+ provider: config,
245
+ certificate: provider.idpCertificate ?? "",
246
+ callbackUrl: ssoCallbackUrl(deps.publicOrigin, config.id),
247
+ samlResponse: input.samlResponse,
248
+ requestId: state.requestId,
249
+ ...(deps.samlValidatorFactory === undefined
250
+ ? {}
251
+ : { validatorFactory: deps.samlValidatorFactory }),
252
+ });
253
+ const resolution = await resolveIdentity(deps.system, claims, identityPolicy(config), {
254
+ defaultRole: deps.defaultRole,
255
+ claimUnverifiedPasswordAccount: deps.verificationEnabled,
256
+ audit: deps.audit,
257
+ });
258
+ return { userId: resolution.userId, redirectTo: postLoginPath(state), resolution };
259
+ }
260
+ /**
261
+ * Completes a login: consumes the single-use state, exchanges the code/assertion and resolves the
262
+ * identity. A failure is rethrown as the safe code the route turns into a redirect.
263
+ */
264
+ export async function completeLogin(
265
+ deps: SsoFlowDeps,
266
+ providerId: string,
267
+ input: CompleteLoginInput,
268
+ ): Promise<CompleteLoginResult> {
269
+ try {
270
+ const provider = resolve(deps, providerId);
271
+ if (input.method === "POST" && provider.config.kind !== "saml") {
272
+ throw new SsoError("sso_state_invalid");
273
+ }
274
+ if (input.method === "GET" && provider.config.kind === "saml") {
275
+ throw new SsoError("sso_state_invalid");
276
+ }
277
+ const result =
278
+ input.method === "POST"
279
+ ? await completePost(deps, provider, input)
280
+ : await completeGet(deps, provider, input);
281
+ deps.audit({
282
+ operation: "auth:sso:login",
283
+ outcome: "succeeded",
284
+ details: { provider: providerId },
285
+ });
286
+ return result;
287
+ } catch (error) {
288
+ deps.audit({
289
+ operation: "auth:sso:login",
290
+ outcome: "failed",
291
+ errorCode: ssoErrorCode(error),
292
+ details: { provider: providerId },
293
+ });
294
+ throw error;
295
+ }
296
+ }