@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,162 @@
1
+ import { and, eq, sql } from "drizzle-orm";
2
+ import { SsoError } from "./errors.js";
3
+ import { authIdentities } from "./tables.js";
4
+ import { normalizeEmail } from "../email.js";
5
+ import { users, userRoles } from "../tables.js";
6
+ /**
7
+ * Account linking and JIT provisioning (D-120).
8
+ *
9
+ * The identity key is `(provider, subject)`: a changed email never breaks an existing link.
10
+ * Without a link, the asserted email must be verified before it can match a user, and a user is
11
+ * only created when the provider's `autoProvision` allows it. Every read/write happens inside the
12
+ * caller's audited `ctx.system` elevation — never on a raw client (D-099).
13
+ *
14
+ * Races are handled with the unique keys the schema already has: `(provider, subject)` for the
15
+ * identity and `users.email` for the user, both `ON CONFLICT DO NOTHING` followed by a re-read.
16
+ */
17
+ export const IDENTITY_TABLES = ["auth_identities", "users", "user_roles"];
18
+ const IDENTITY_ELEVATION = {
19
+ actor: "system:auth:sso",
20
+ operation: "auth:sso:identity:resolve",
21
+ reason: "Linking or provisioning the user happens before the SSO session exists.",
22
+ tables: IDENTITY_TABLES,
23
+ };
24
+ function roleFor(provider, options) {
25
+ return provider.defaultRole ?? options.defaultRole;
26
+ }
27
+ async function findIdentityUserId(db, provider, subject) {
28
+ const rows = await db
29
+ .select({ userId: authIdentities.userId })
30
+ .from(authIdentities)
31
+ .where(and(eq(authIdentities.provider, provider), eq(authIdentities.subject, subject)))
32
+ .limit(1);
33
+ return rows[0]?.userId ?? null;
34
+ }
35
+ async function findUserByEmail(db, email) {
36
+ const rows = await db
37
+ .select({
38
+ id: users.id,
39
+ passwordHash: users.passwordHash,
40
+ emailVerifiedAt: users.emailVerifiedAt,
41
+ })
42
+ .from(users)
43
+ .where(sql `lower(${users.email}) = ${normalizeEmail(email)}`)
44
+ .limit(1);
45
+ return rows[0] ?? null;
46
+ }
47
+ /**
48
+ * A service account exists only to back an API client: it must never complete an interactive
49
+ * login. The generic `sso_account_not_found` is deliberate — the flow must not become a probe
50
+ * for which addresses belong to a service identity.
51
+ */
52
+ async function assertLoginable(db, userId) {
53
+ const rows = await db
54
+ .select({ isService: users.isService })
55
+ .from(users)
56
+ .where(eq(users.id, userId))
57
+ .limit(1);
58
+ if (rows[0]?.isService === true)
59
+ throw new SsoError("sso_account_not_found");
60
+ }
61
+ async function touchIdentity(db, claims, now) {
62
+ await db
63
+ .update(authIdentities)
64
+ .set({ email: normalizeEmail(claims.email), lastLoginAt: now })
65
+ .where(and(eq(authIdentities.provider, claims.provider), eq(authIdentities.subject, claims.subject)));
66
+ }
67
+ /**
68
+ * Resolves the Facilis user for a verified identity.
69
+ *
70
+ * Throws `sso_email_unverified` for an unverified email that has no existing link and
71
+ * `sso_account_not_found` when provisioning is disabled; both are safe to redirect.
72
+ */
73
+ export async function resolveIdentity(system, claims, provider, options, now = new Date()) {
74
+ return system(IDENTITY_ELEVATION, async (db) => {
75
+ const linked = await findIdentityUserId(db, claims.provider, claims.subject);
76
+ if (linked !== null) {
77
+ await assertLoginable(db, linked);
78
+ await touchIdentity(db, claims, now);
79
+ return { userId: linked, outcome: "existing" };
80
+ }
81
+ // An unverified email must never link to (or provision) an account: that is account takeover.
82
+ if (!claims.emailVerified)
83
+ throw new SsoError("sso_email_unverified");
84
+ const existingUser = await findUserByEmail(db, claims.email);
85
+ if (existingUser !== null) {
86
+ await assertLoginable(db, existingUser.id);
87
+ // Account pre-hijacking (SEC-02): a pending password account was registered by someone
88
+ // who never proved they own the address, and with email verification enabled it cannot
89
+ // even log in. The verified IdP email IS that proof now, so the identity claims the
90
+ // account and the placeholder password is removed before linking (D-156): whoever
91
+ // registered the address first loses it, the mailbox owner keeps it. A verified password
92
+ // account belongs to the same proven mailbox and links without clearing anything.
93
+ const claimed = existingUser.passwordHash !== null && existingUser.emailVerifiedAt === null;
94
+ if (claimed) {
95
+ // With verification disabled the account was never meant to prove its mailbox: refuse
96
+ // rather than silently reset a password its owner may still use (D-156).
97
+ if (!options.claimUnverifiedPasswordAccount)
98
+ throw new SsoError("sso_email_conflict");
99
+ await db
100
+ .update(users)
101
+ .set({ passwordHash: null, emailVerifiedAt: now })
102
+ .where(eq(users.id, existingUser.id));
103
+ }
104
+ const inserted = await db
105
+ .insert(authIdentities)
106
+ .values({
107
+ provider: claims.provider,
108
+ subject: claims.subject,
109
+ userId: existingUser.id,
110
+ email: normalizeEmail(claims.email),
111
+ })
112
+ .onConflictDoNothing()
113
+ .returning({ userId: authIdentities.userId });
114
+ const raced = inserted[0]?.userId ?? (await findIdentityUserId(db, claims.provider, claims.subject));
115
+ if (raced === null)
116
+ throw new SsoError("sso_internal_error", "identity link race");
117
+ await touchIdentity(db, claims, now);
118
+ options.audit({
119
+ operation: "auth:sso:link",
120
+ outcome: "succeeded",
121
+ tables: claimed ? ["auth_identities", "users"] : ["auth_identities"],
122
+ details: {
123
+ provider: claims.provider,
124
+ ...(claimed ? { claimed: "unverified_password" } : {}),
125
+ },
126
+ });
127
+ return { userId: raced, outcome: "linked" };
128
+ }
129
+ if (!provider.autoProvision)
130
+ throw new SsoError("sso_account_not_found");
131
+ const role = roleFor(provider, options);
132
+ const created = await db
133
+ .insert(users)
134
+ .values({ email: normalizeEmail(claims.email), passwordHash: null })
135
+ .onConflictDoNothing()
136
+ .returning({ id: users.id });
137
+ const userId = created[0]?.id ?? (await findUserByEmail(db, claims.email))?.id ?? null;
138
+ if (userId === null)
139
+ throw new SsoError("sso_internal_error", "user provision race");
140
+ await db.insert(userRoles).values({ userId, role }).onConflictDoNothing();
141
+ const identity = await db
142
+ .insert(authIdentities)
143
+ .values({
144
+ provider: claims.provider,
145
+ subject: claims.subject,
146
+ userId,
147
+ email: normalizeEmail(claims.email),
148
+ })
149
+ .onConflictDoNothing()
150
+ .returning({ userId: authIdentities.userId });
151
+ const resolved = identity[0]?.userId ?? (await findIdentityUserId(db, claims.provider, claims.subject));
152
+ if (resolved === null)
153
+ throw new SsoError("sso_internal_error", "identity provision race");
154
+ options.audit({
155
+ operation: "auth:sso:provision",
156
+ outcome: "succeeded",
157
+ tables: ["users", "user_roles", "auth_identities"],
158
+ details: { provider: claims.provider, role },
159
+ });
160
+ return { userId: resolved, outcome: "provisioned" };
161
+ });
162
+ }
@@ -0,0 +1,27 @@
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
+ export { authIdentities, authSsoStates } from "./tables.js";
7
+ export type { AuthIdentityRow, AuthSsoStateRow, NewAuthIdentityRow, NewAuthSsoStateRow, } from "./tables.js";
8
+ export { SSO_ERROR_CODES, failureRedirectPath, isSsoError, SsoError, ssoErrorCode, } from "./errors.js";
9
+ export type { SsoErrorCode } from "./errors.js";
10
+ export { isProviderConfigured, missingCredentialEnv, publicProviderList, resolveProvider, SSO_NAMESPACE, } from "./providers.js";
11
+ export type { Oauth2Mapping, Oauth2ProviderConfig, OidcProviderConfig, PublicSsoProvider, ResolvedSsoProvider, SamlProviderConfig, SsoConfigLike, SsoProviderBase, SsoProviderConfig, SsoProviderKind, SsoTokenAuth, } from "./providers.js";
12
+ export { consumeSsoState, createSsoState, MAX_REDIRECT_TO, normalizeRedirectTo } from "./state.js";
13
+ export type { CreateSsoStateInput } from "./state.js";
14
+ export { buildOidcAuthorizeUrl, clearOidcDiscoveryCache, discoverOidc, exchangeOidcCode, OIDC_DISCOVERY_TTL_MS, resolveOidcIdentity, verifyOidcIdToken, } from "./oidc.js";
15
+ export { createPkcePair } from "./pkce.js";
16
+ export type { OidcAuthorizeInput, OidcClaims, OidcDeps, OidcDiscovery, OidcTokenInput, } from "./oidc.js";
17
+ export type { PkcePair } from "./pkce.js";
18
+ export { buildOauth2AuthorizeUrl, exchangeOauth2Code, readDottedPath, readVerifiedClaim, requestToken, resolveOauth2Identity, resolveOauth2IdentityFromCode, } from "./oauth2.js";
19
+ export type { Oauth2Identity, Oauth2TokenInput, RequestTokenInput, TokenClientConfig, TokenPayload, } from "./oauth2.js";
20
+ export { buildAuthnRequestXml, buildSamlRedirect, validateSamlResponse } from "./saml.js";
21
+ export type { BuiltSamlRequest, SamlProfileLike, SamlValidator, SamlValidatorFactory, ValidateSamlInput, } from "./saml.js";
22
+ export { IDENTITY_TABLES, resolveIdentity } from "./identity.js";
23
+ export type { IdentityOutcome, IdentityProviderPolicy, IdentityResolution, ResolveIdentityOptions, } from "./identity.js";
24
+ export { completeLogin, startLogin } from "./login.js";
25
+ export { DEFAULT_POST_LOGIN_PATH, postLoginPath, ssoCallbackUrl } from "./paths.js";
26
+ export type { CompleteLoginInput, CompleteLoginResult, SsoFlowDeps } from "./login.js";
27
+ export type { FetchInit, FetchLike, FetchResponse, IdentityClaims, SsoAudit, SsoAuditInput, SystemElevationOptions, SystemRunner, } from "./contracts.js";
@@ -0,0 +1,16 @@
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
+ export { authIdentities, authSsoStates } from "./tables.js";
7
+ export { SSO_ERROR_CODES, failureRedirectPath, isSsoError, SsoError, ssoErrorCode, } from "./errors.js";
8
+ export { isProviderConfigured, missingCredentialEnv, publicProviderList, resolveProvider, SSO_NAMESPACE, } from "./providers.js";
9
+ export { consumeSsoState, createSsoState, MAX_REDIRECT_TO, normalizeRedirectTo } from "./state.js";
10
+ export { buildOidcAuthorizeUrl, clearOidcDiscoveryCache, discoverOidc, exchangeOidcCode, OIDC_DISCOVERY_TTL_MS, resolveOidcIdentity, verifyOidcIdToken, } from "./oidc.js";
11
+ export { createPkcePair } from "./pkce.js";
12
+ export { buildOauth2AuthorizeUrl, exchangeOauth2Code, readDottedPath, readVerifiedClaim, requestToken, resolveOauth2Identity, resolveOauth2IdentityFromCode, } from "./oauth2.js";
13
+ export { buildAuthnRequestXml, buildSamlRedirect, validateSamlResponse } from "./saml.js";
14
+ export { IDENTITY_TABLES, resolveIdentity } from "./identity.js";
15
+ export { completeLogin, startLogin } from "./login.js";
16
+ export { DEFAULT_POST_LOGIN_PATH, postLoginPath, ssoCallbackUrl } from "./paths.js";
@@ -0,0 +1,60 @@
1
+ import type { FetchLike, SsoAudit, SystemRunner } from "./contracts.js";
2
+ import { type IdentityResolution } from "./identity.js";
3
+ import { type OidcDeps } from "./oidc.js";
4
+ import { type SsoConfigLike } from "./providers.js";
5
+ import { type SamlValidatorFactory } from "./saml.js";
6
+ /**
7
+ * The two ends of an SSO login (D-120): start builds the IdP redirect and stores the single-use
8
+ * attempt; complete consumes that attempt, resolves the identity and returns the user.
9
+ *
10
+ * The route owns the HTTP envelope (302, cookie, failure redirect); this module owns the protocol
11
+ * and never exposes an internal message. Every failure is an {@link SsoError} whose code is safe
12
+ * to append to `failureRedirect`.
13
+ */
14
+ export interface SsoFlowDeps {
15
+ /** `config.auth.sso` after defaults. */
16
+ readonly sso: SsoConfigLike;
17
+ /** `config.auth.defaultRole`: the role a provisioned user gets without a provider override. */
18
+ readonly defaultRole: string;
19
+ /** `config.auth.verification.enabled`: allows the SSO identity to claim a pending account. */
20
+ readonly verificationEnabled: boolean;
21
+ /** Deployment origin (`FACILIS_PUBLIC_ORIGIN` or the request origin), no trailing slash. */
22
+ readonly publicOrigin: string;
23
+ readonly system: SystemRunner;
24
+ readonly audit: SsoAudit;
25
+ readonly fetch?: FetchLike;
26
+ readonly now?: () => number;
27
+ readonly environment?: Readonly<Record<string, string | undefined>>;
28
+ /** Diagnostic line for an unusable provider; must never receive a secret value. */
29
+ readonly warn?: (message: string, data: Record<string, unknown>) => void;
30
+ /** Test seam for discovery/JWKS; defaults to the flow's fetch/now. */
31
+ readonly oidc?: OidcDeps;
32
+ /** Test seam for the lazily imported SAML validator. */
33
+ readonly samlValidatorFactory?: SamlValidatorFactory;
34
+ }
35
+ /** Starts a login and returns the IdP URL; the attempt is stored as one single-use row. */
36
+ export declare function startLogin(deps: SsoFlowDeps, input: {
37
+ readonly providerId: string;
38
+ readonly redirectTo: string | null;
39
+ }): Promise<{
40
+ readonly redirectUrl: string;
41
+ }>;
42
+ export type CompleteLoginInput = {
43
+ readonly method: "GET";
44
+ readonly code: string;
45
+ readonly state: string;
46
+ } | {
47
+ readonly method: "POST";
48
+ readonly samlResponse: string;
49
+ readonly relayState: string;
50
+ };
51
+ export interface CompleteLoginResult {
52
+ readonly userId: string;
53
+ readonly redirectTo: string;
54
+ readonly resolution: IdentityResolution;
55
+ }
56
+ /**
57
+ * Completes a login: consumes the single-use state, exchanges the code/assertion and resolves the
58
+ * identity. A failure is rethrown as the safe code the route turns into a redirect.
59
+ */
60
+ export declare function completeLogin(deps: SsoFlowDeps, providerId: string, input: CompleteLoginInput): Promise<CompleteLoginResult>;
@@ -0,0 +1,209 @@
1
+ import { randomToken } from "../crypto.js";
2
+ import { isSsoError, SsoError, ssoErrorCode } from "./errors.js";
3
+ import { resolveIdentity } from "./identity.js";
4
+ import { buildOidcAuthorizeUrl, discoverOidc, exchangeOidcCode, resolveOidcIdentity, } from "./oidc.js";
5
+ import { buildOauth2AuthorizeUrl, resolveOauth2IdentityFromCode } from "./oauth2.js";
6
+ import { createPkcePair } from "./pkce.js";
7
+ import { postLoginPath, ssoCallbackUrl } from "./paths.js";
8
+ import { identityPolicy, missingCredentialEnv, resolveProvider, } from "./providers.js";
9
+ import { buildSamlRedirect, validateSamlResponse } from "./saml.js";
10
+ import { consumeSsoState, createSsoState, normalizeRedirectTo } from "./state.js";
11
+ function oidcDeps(deps) {
12
+ if (deps.oidc !== undefined)
13
+ return deps.oidc;
14
+ return {
15
+ ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
16
+ ...(deps.now === undefined ? {} : { now: deps.now }),
17
+ };
18
+ }
19
+ function resolve(deps, providerId) {
20
+ try {
21
+ return resolveProvider(deps.sso, providerId, deps.environment ?? process.env);
22
+ }
23
+ catch (error) {
24
+ if (isSsoError(error) && error.code === "sso_provider_unavailable") {
25
+ const provider = deps.sso.providers.find((candidate) => candidate.id === providerId);
26
+ const envName = provider === undefined ? undefined : missingCredentialEnv(provider);
27
+ // The warning names the provider and the ENV VAR, never its value (D-120).
28
+ deps.warn?.("sso provider is not configured", {
29
+ provider: providerId,
30
+ ...(envName === undefined ? {} : { env: envName }),
31
+ });
32
+ }
33
+ throw error;
34
+ }
35
+ }
36
+ function nowOf(deps) {
37
+ return deps.now?.() ?? Date.now();
38
+ }
39
+ function tokenClient(config, secret) {
40
+ if (config.kind === "saml")
41
+ throw new SsoError("sso_state_invalid");
42
+ return {
43
+ clientId: config.clientId,
44
+ tokenAuth: config.tokenAuth,
45
+ ...(secret === undefined ? {} : { clientSecret: secret }),
46
+ };
47
+ }
48
+ async function startRedirect(deps, input, provider) {
49
+ const config = provider.config;
50
+ const redirectTo = normalizeRedirectTo(input.redirectTo);
51
+ const now = nowOf(deps);
52
+ const redirectUri = ssoCallbackUrl(deps.publicOrigin, config.id);
53
+ if (config.kind === "saml") {
54
+ const state = randomToken(32);
55
+ const built = await buildSamlRedirect(config, redirectUri, state, now);
56
+ await createSsoState(deps.system, {
57
+ provider: config.id,
58
+ ttlMs: deps.sso.stateTtlMs,
59
+ redirectTo,
60
+ requestId: built.requestId,
61
+ state,
62
+ }, now);
63
+ return built.redirectUrl;
64
+ }
65
+ if (config.kind === "oidc") {
66
+ const discovery = await discoverOidc(config, oidcDeps(deps));
67
+ const pkce = await createPkcePair();
68
+ const nonce = randomToken(32);
69
+ const state = await createSsoState(deps.system, {
70
+ provider: config.id,
71
+ ttlMs: deps.sso.stateTtlMs,
72
+ redirectTo,
73
+ nonce,
74
+ codeVerifier: pkce.verifier,
75
+ }, now);
76
+ return buildOidcAuthorizeUrl(config, discovery, {
77
+ redirectUri,
78
+ state: state.state,
79
+ nonce,
80
+ codeChallenge: pkce.challenge,
81
+ });
82
+ }
83
+ const pkce = await createPkcePair();
84
+ const state = await createSsoState(deps.system, {
85
+ provider: config.id,
86
+ ttlMs: deps.sso.stateTtlMs,
87
+ redirectTo,
88
+ codeVerifier: pkce.verifier,
89
+ }, now);
90
+ return buildOauth2AuthorizeUrl(config, redirectUri, state.state, pkce.challenge);
91
+ }
92
+ /** Starts a login and returns the IdP URL; the attempt is stored as one single-use row. */
93
+ export async function startLogin(deps, input) {
94
+ try {
95
+ const provider = resolve(deps, input.providerId);
96
+ const redirectUrl = await startRedirect(deps, input, provider);
97
+ deps.audit({
98
+ operation: "auth:sso:start",
99
+ outcome: "succeeded",
100
+ details: { provider: input.providerId },
101
+ });
102
+ return { redirectUrl };
103
+ }
104
+ catch (error) {
105
+ deps.audit({
106
+ operation: "auth:sso:start",
107
+ outcome: "failed",
108
+ errorCode: ssoErrorCode(error),
109
+ details: { provider: input.providerId },
110
+ });
111
+ throw error;
112
+ }
113
+ }
114
+ async function claimsFromCode(deps, provider, code, state) {
115
+ const config = provider.config;
116
+ if (state.provider !== config.id)
117
+ throw new SsoError("sso_state_invalid");
118
+ if (config.kind === "oidc") {
119
+ if (state.nonce === null || state.codeVerifier === null)
120
+ throw new SsoError("sso_state_invalid");
121
+ const discovery = await discoverOidc(config, oidcDeps(deps));
122
+ const token = await exchangeOidcCode(tokenClient(config, provider.clientSecret), discovery, {
123
+ code,
124
+ redirectUri: ssoCallbackUrl(deps.publicOrigin, config.id),
125
+ codeVerifier: state.codeVerifier,
126
+ ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
127
+ });
128
+ return resolveOidcIdentity(config, discovery, token, state.nonce, oidcDeps(deps));
129
+ }
130
+ if (config.kind === "oauth2") {
131
+ if (state.codeVerifier === null)
132
+ throw new SsoError("sso_state_invalid");
133
+ const identity = await resolveOauth2IdentityFromCode(tokenClient(config, provider.clientSecret), config, {
134
+ code,
135
+ redirectUri: ssoCallbackUrl(deps.publicOrigin, config.id),
136
+ codeVerifier: state.codeVerifier,
137
+ ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
138
+ });
139
+ return { provider: config.id, ...identity };
140
+ }
141
+ throw new SsoError("sso_state_invalid");
142
+ }
143
+ async function completeGet(deps, provider, input) {
144
+ const state = await consumeSsoState(deps.system, input.state, nowOf(deps));
145
+ const claims = await claimsFromCode(deps, provider, input.code, state);
146
+ const resolution = await resolveIdentity(deps.system, claims, identityPolicy(provider.config), {
147
+ defaultRole: deps.defaultRole,
148
+ claimUnverifiedPasswordAccount: deps.verificationEnabled,
149
+ audit: deps.audit,
150
+ });
151
+ return { userId: resolution.userId, redirectTo: postLoginPath(state), resolution };
152
+ }
153
+ async function completePost(deps, provider, input) {
154
+ const config = provider.config;
155
+ if (config.kind !== "saml")
156
+ throw new SsoError("sso_state_invalid");
157
+ const state = await consumeSsoState(deps.system, input.relayState, nowOf(deps));
158
+ if (state.provider !== config.id)
159
+ throw new SsoError("sso_state_invalid");
160
+ const claims = await validateSamlResponse({
161
+ provider: config,
162
+ certificate: provider.idpCertificate ?? "",
163
+ callbackUrl: ssoCallbackUrl(deps.publicOrigin, config.id),
164
+ samlResponse: input.samlResponse,
165
+ requestId: state.requestId,
166
+ ...(deps.samlValidatorFactory === undefined
167
+ ? {}
168
+ : { validatorFactory: deps.samlValidatorFactory }),
169
+ });
170
+ const resolution = await resolveIdentity(deps.system, claims, identityPolicy(config), {
171
+ defaultRole: deps.defaultRole,
172
+ claimUnverifiedPasswordAccount: deps.verificationEnabled,
173
+ audit: deps.audit,
174
+ });
175
+ return { userId: resolution.userId, redirectTo: postLoginPath(state), resolution };
176
+ }
177
+ /**
178
+ * Completes a login: consumes the single-use state, exchanges the code/assertion and resolves the
179
+ * identity. A failure is rethrown as the safe code the route turns into a redirect.
180
+ */
181
+ export async function completeLogin(deps, providerId, input) {
182
+ try {
183
+ const provider = resolve(deps, providerId);
184
+ if (input.method === "POST" && provider.config.kind !== "saml") {
185
+ throw new SsoError("sso_state_invalid");
186
+ }
187
+ if (input.method === "GET" && provider.config.kind === "saml") {
188
+ throw new SsoError("sso_state_invalid");
189
+ }
190
+ const result = input.method === "POST"
191
+ ? await completePost(deps, provider, input)
192
+ : await completeGet(deps, provider, input);
193
+ deps.audit({
194
+ operation: "auth:sso:login",
195
+ outcome: "succeeded",
196
+ details: { provider: providerId },
197
+ });
198
+ return result;
199
+ }
200
+ catch (error) {
201
+ deps.audit({
202
+ operation: "auth:sso:login",
203
+ outcome: "failed",
204
+ errorCode: ssoErrorCode(error),
205
+ details: { provider: providerId },
206
+ });
207
+ throw error;
208
+ }
209
+ }
@@ -0,0 +1,52 @@
1
+ import { type FetchLike } from "./contracts.js";
2
+ import type { Oauth2ProviderConfig, SsoTokenAuth } from "./providers.js";
3
+ export interface TokenPayload {
4
+ readonly accessToken: string;
5
+ readonly idToken?: string;
6
+ }
7
+ /** The subset of a provider the token endpoint needs; OIDC and OAuth2 share it. */
8
+ export interface TokenClientConfig {
9
+ readonly clientId: string;
10
+ readonly clientSecret?: string;
11
+ readonly tokenAuth: SsoTokenAuth;
12
+ }
13
+ export interface RequestTokenInput {
14
+ readonly endpoint: string;
15
+ readonly client: TokenClientConfig;
16
+ readonly form: Readonly<Record<string, string>>;
17
+ readonly fetch?: FetchLike;
18
+ }
19
+ /**
20
+ * POSTs an authorization-code grant to the token endpoint with the configured client auth mode.
21
+ *
22
+ * The response is parsed for `access_token`/`id_token` only; a provider error body is never
23
+ * surfaced (it can echo the code), just the safe `sso_exchange_failed` code.
24
+ */
25
+ export declare function requestToken(input: RequestTokenInput): Promise<TokenPayload>;
26
+ /** Reads a dotted path (`data.user.email`) out of parsed userinfo JSON. */
27
+ export declare function readDottedPath(value: unknown, path: string): unknown;
28
+ /** Truthiness of a verification claim: boolean true or the string "true" only. */
29
+ export declare function readVerifiedClaim(value: unknown): boolean | undefined;
30
+ /**
31
+ * The authorization URL: response_type=code, the configured scopes, state, PKCE S256 and the
32
+ * redirect URI. PKCE is sent unconditionally (RFC 6749 lets a provider ignore it) so a public
33
+ * `tokenAuth: "none"` client is protected exactly like the OIDC flow (D-120).
34
+ */
35
+ export declare function buildOauth2AuthorizeUrl(provider: Oauth2ProviderConfig, redirectUri: string, state: string, codeChallenge: string): string;
36
+ export interface Oauth2TokenInput {
37
+ readonly code: string;
38
+ readonly redirectUri: string;
39
+ /** PKCE verifier stored in the single-use state row; required, like the OIDC flow. */
40
+ readonly codeVerifier: string;
41
+ readonly fetch?: FetchLike;
42
+ }
43
+ export declare function exchangeOauth2Code(client: TokenClientConfig, provider: Oauth2ProviderConfig, input: Oauth2TokenInput): Promise<TokenPayload>;
44
+ export interface Oauth2Identity {
45
+ readonly subject: string;
46
+ readonly email: string;
47
+ readonly emailVerified: boolean;
48
+ }
49
+ /** Maps the userinfo JSON onto the identity key via the configured dotted paths. */
50
+ export declare function resolveOauth2Identity(provider: Oauth2ProviderConfig, userinfo: Record<string, unknown>): Oauth2Identity;
51
+ /** Token exchange + userinfo + mapping, the complete generic-OAuth2 identity resolution. */
52
+ export declare function resolveOauth2IdentityFromCode(client: TokenClientConfig, provider: Oauth2ProviderConfig, input: Oauth2TokenInput): Promise<Oauth2Identity>;