@robelest/convex-auth 0.0.4-preview.30 → 0.0.4-preview.31

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 (105) hide show
  1. package/dist/bin.js +125 -36
  2. package/dist/browser/index.d.ts +3 -13
  3. package/dist/browser/index.js +47 -12
  4. package/dist/browser/navigation.js +1 -1
  5. package/dist/browser/passkey.js +7 -7
  6. package/dist/browser/runtime.js +13 -15
  7. package/dist/client/core/types.d.ts +179 -63
  8. package/dist/client/core/types.js +6 -0
  9. package/dist/client/factors/totp.js +1 -1
  10. package/dist/client/index.d.ts +5 -4
  11. package/dist/client/index.js +115 -56
  12. package/dist/client/runtime/mutex.js +3 -2
  13. package/dist/component/_generated/component.d.ts +40 -0
  14. package/dist/component/convex.config.d.ts +2 -2
  15. package/dist/component/http.js +9 -0
  16. package/dist/component/index.d.ts +1 -1
  17. package/dist/component/model.d.ts +25 -25
  18. package/dist/component/model.js +2 -1
  19. package/dist/component/modules.js +1 -0
  20. package/dist/component/public/factors/passkeys.js +31 -1
  21. package/dist/component/public/identity/codes.js +1 -1
  22. package/dist/component/public/identity/tokens.js +2 -1
  23. package/dist/component/public/identity/verifiers.js +15 -5
  24. package/dist/component/public.js +2 -2
  25. package/dist/component/schema.d.ts +292 -290
  26. package/dist/component/schema.js +2 -1
  27. package/dist/core/index.d.ts +8 -3
  28. package/dist/core/index.js +7 -2
  29. package/dist/expo/index.d.ts +21 -0
  30. package/dist/expo/index.js +148 -0
  31. package/dist/expo/passkey.js +174 -0
  32. package/dist/providers/apple.d.ts +1 -1
  33. package/dist/providers/apple.js +6 -8
  34. package/dist/providers/custom.d.ts +1 -1
  35. package/dist/providers/custom.js +4 -7
  36. package/dist/providers/github.d.ts +1 -1
  37. package/dist/providers/github.js +5 -8
  38. package/dist/providers/google.d.ts +1 -1
  39. package/dist/providers/google.js +5 -8
  40. package/dist/providers/microsoft.d.ts +1 -1
  41. package/dist/providers/microsoft.js +5 -9
  42. package/dist/providers/password.d.ts +18 -37
  43. package/dist/providers/password.js +170 -115
  44. package/dist/providers/redirect.d.ts +1 -0
  45. package/dist/providers/redirect.js +20 -0
  46. package/dist/server/auth.d.ts +6 -7
  47. package/dist/server/auth.js +3 -2
  48. package/dist/server/{ctxCache.js → cache/context.js} +2 -2
  49. package/dist/server/{componentContext.d.ts → component/context.d.ts} +2 -2
  50. package/dist/server/context.js +3 -10
  51. package/dist/server/contract.d.ts +2 -87
  52. package/dist/server/contract.js +1 -1
  53. package/dist/server/cookies.js +25 -1
  54. package/dist/server/core.js +1 -1
  55. package/dist/server/errors.js +24 -1
  56. package/dist/server/{auth-context.d.ts → facade.d.ts} +3 -45
  57. package/dist/server/{auth-context.js → facade.js} +11 -12
  58. package/dist/server/http.d.ts +7 -7
  59. package/dist/server/http.js +36 -7
  60. package/dist/server/{convexIdentity.d.ts → identity/convex.d.ts} +3 -3
  61. package/dist/server/index.d.ts +5 -3
  62. package/dist/server/index.js +3 -2
  63. package/dist/server/mounts.d.ts +175 -22
  64. package/dist/server/mutations/code.js +7 -1
  65. package/dist/server/mutations/{credentialsSignIn.js → credentials/signin.js} +10 -10
  66. package/dist/server/mutations/index.js +1 -1
  67. package/dist/server/mutations/invalidate.js +11 -1
  68. package/dist/server/mutations/oauth.js +25 -27
  69. package/dist/server/mutations/signin.js +6 -0
  70. package/dist/server/mutations/signout.js +5 -0
  71. package/dist/server/mutations/store.js +1 -1
  72. package/dist/server/oauth/factory.js +11 -3
  73. package/dist/server/passkey.js +126 -110
  74. package/dist/server/prefetch.js +8 -1
  75. package/dist/server/redirects.js +11 -3
  76. package/dist/server/refresh.js +6 -1
  77. package/dist/server/runtime.d.ts +58 -36
  78. package/dist/server/runtime.js +333 -36
  79. package/dist/server/services/group.js +4 -0
  80. package/dist/server/sessions.js +1 -0
  81. package/dist/server/signin.js +8 -6
  82. package/dist/server/sso/domain.d.ts +159 -16
  83. package/dist/server/sso/domain.js +1 -1
  84. package/dist/server/sso/http.js +144 -60
  85. package/dist/server/sso/oidc.js +28 -12
  86. package/dist/server/sso/policy.js +30 -14
  87. package/dist/server/sso/provision.js +1 -1
  88. package/dist/server/sso/saml.js +18 -9
  89. package/dist/server/sso/scim.js +12 -4
  90. package/dist/server/sso/shared.js +5 -5
  91. package/dist/server/telemetry.js +3 -0
  92. package/dist/server/tokens.js +10 -2
  93. package/dist/server/totp.js +127 -100
  94. package/dist/server/types.d.ts +224 -151
  95. package/dist/server/url.js +1 -1
  96. package/dist/server/users.js +93 -53
  97. package/dist/server/wellknown.d.ts +130 -0
  98. package/dist/server/wellknown.js +195 -0
  99. package/dist/shared/errors.js +0 -1
  100. package/package.json +36 -4
  101. package/dist/server/oauth/index.js +0 -12
  102. package/dist/server/utils/dispatch.js +0 -36
  103. package/dist/shared/authResults.d.ts +0 -16
  104. /package/dist/server/{componentContext.js → component/context.js} +0 -0
  105. /package/dist/server/{convexIdentity.js → identity/convex.js} +0 -0
@@ -44,7 +44,9 @@ function validateOidcUserInfo(data) {
44
44
  email: typeof obj.email === "string" ? obj.email : void 0,
45
45
  email_verified: typeof obj.email_verified === "boolean" ? obj.email_verified : void 0,
46
46
  name: typeof obj.name === "string" ? obj.name : void 0,
47
- picture: typeof obj.picture === "string" ? obj.picture : void 0
47
+ picture: typeof obj.picture === "string" ? obj.picture : void 0,
48
+ groups: obj.groups,
49
+ roles: obj.roles
48
50
  };
49
51
  }
50
52
  const asError = (error) => error instanceof Error ? error : new Error(String(error));
@@ -127,7 +129,9 @@ async function userInfoProfileFx(opts) {
127
129
  email: typeof userInfo.email === "string" ? userInfo.email : opts.verifiedProfile.email,
128
130
  emailVerified: typeof userInfo.email_verified === "boolean" ? userInfo.email_verified : opts.verifiedProfile.emailVerified,
129
131
  name: typeof userInfo.name === "string" ? userInfo.name : opts.verifiedProfile.name,
130
- image: typeof userInfo.picture === "string" ? userInfo.picture : opts.verifiedProfile.image
132
+ image: typeof userInfo.picture === "string" ? userInfo.picture : opts.verifiedProfile.image,
133
+ groups: normalizeStringArray(userInfo.groups) ?? opts.verifiedProfile.groups,
134
+ roles: normalizeStringArray(userInfo.roles) ?? opts.verifiedProfile.roles
131
135
  };
132
136
  });
133
137
  }
@@ -151,6 +155,7 @@ async function createGroupConnectionOidcProvider(config, redirectUri) {
151
155
  const supportedIdTokenSigningAlgs = Array.isArray(discovery.id_token_signing_alg_values_supported) ? discovery.id_token_signing_alg_values_supported.filter((value) => typeof value === "string") : [];
152
156
  const discoveredTokenEndpointAuthMethods = Array.isArray(discovery.token_endpoint_auth_methods_supported) ? discovery.token_endpoint_auth_methods_supported.filter((value) => typeof value === "string") : [];
153
157
  const tokenEndpointAuthMethod = client.authMethod === "client_secret_basic" || client.authMethod === "client_secret_post" ? client.authMethod : discoveredTokenEndpointAuthMethods.includes("client_secret_basic") ? "client_secret_basic" : "client_secret_post";
158
+ if (typeof client.authMethod === "string" && discoveredTokenEndpointAuthMethods.length > 0 && !discoveredTokenEndpointAuthMethods.includes(tokenEndpointAuthMethod)) throw new Error(`OIDC token endpoint auth method ${tokenEndpointAuthMethod} is not advertised by discovery.`);
154
159
  const userinfoEndpoint = discovery.userinfo_endpoint ?? void 0;
155
160
  const claimMapping = typeof profile.mapping === "object" && profile.mapping !== null ? profile.mapping : void 0;
156
161
  const oidcFetch = createGroupConnectionOidcFetch(config, discovery.issuer);
@@ -163,13 +168,8 @@ async function createGroupConnectionOidcProvider(config, redirectUri) {
163
168
  ];
164
169
  const expectedAudience = Array.isArray(discoveryConfig.audience) ? discoveryConfig.audience.filter((value) => typeof value === "string") : typeof discoveryConfig.audience === "string" ? discoveryConfig.audience : String(client.id);
165
170
  const clockToleranceSeconds = typeof security.clockToleranceSeconds === "number" ? security.clockToleranceSeconds : 10;
166
- const getIssuerCandidates = (issuer) => {
167
- const candidates = [issuer];
168
- if (issuer.startsWith("https://")) candidates.push(`http://${issuer.slice(8)}`);
169
- else if (issuer.startsWith("http://")) candidates.push(`https://${issuer.slice(7)}`);
170
- return candidates;
171
- };
172
- const expectedIssuers = strictIssuer ? [expectedIssuer] : Array.from(new Set([...getIssuerCandidates(expectedIssuer), ...getIssuerCandidates(discoveredIssuer)]));
171
+ if (clockToleranceSeconds < 0 || clockToleranceSeconds > 300) throw new Error("OIDC clockToleranceSeconds must be between 0 and 300.");
172
+ const expectedIssuers = strictIssuer ? [expectedIssuer] : Array.from(new Set([expectedIssuer, discoveredIssuer]));
173
173
  const jwks = getOidcJwks(jwksUri, runtimeOrigin, externalHost, oidcFetch);
174
174
  let verifiedClaims = null;
175
175
  let verifiedProfile = null;
@@ -190,7 +190,21 @@ async function createGroupConnectionOidcProvider(config, redirectUri) {
190
190
  if (nonce !== void 0) url.searchParams.set("nonce", nonce);
191
191
  if (typeof loginHint === "string") url.searchParams.set("login_hint", loginHint);
192
192
  const authorizationParams = typeof request.authorizationParams === "object" && request.authorizationParams !== null ? request.authorizationParams : {};
193
- for (const [key, value] of Object.entries(authorizationParams)) if (typeof value === "string") url.searchParams.set(key, value);
193
+ const reservedAuthorizationParams = new Set([
194
+ "response_type",
195
+ "client_id",
196
+ "redirect_uri",
197
+ "scope",
198
+ "state",
199
+ "code_challenge",
200
+ "code_challenge_method",
201
+ "nonce",
202
+ "login_hint"
203
+ ]);
204
+ for (const [key, value] of Object.entries(authorizationParams)) {
205
+ if (reservedAuthorizationParams.has(key)) throw new Error(`OIDC authorizationParams cannot override reserved parameter: ${key}`);
206
+ if (typeof value === "string") url.searchParams.set(key, value);
207
+ }
194
208
  return url;
195
209
  },
196
210
  async validateAuthorizationCode({ code, codeVerifier }) {
@@ -201,7 +215,9 @@ async function createGroupConnectionOidcProvider(config, redirectUri) {
201
215
  });
202
216
  const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded" });
203
217
  if (typeof client.secret === "string" && tokenEndpointAuthMethod === "client_secret_basic") {
204
- const basicAuth = typeof btoa === "function" ? btoa(`${String(client.id)}:${client.secret}`) : Buffer.from(`${String(client.id)}:${client.secret}`).toString("base64");
218
+ const encodeCredential = (value) => encodeURIComponent(value).replace(/%20/g, "+");
219
+ const credentials = `${encodeCredential(String(client.id))}:${encodeCredential(client.secret)}`;
220
+ const basicAuth = typeof btoa === "function" ? btoa(credentials) : Buffer.from(credentials).toString("base64");
205
221
  headers.set("Authorization", `Basic ${basicAuth}`);
206
222
  } else {
207
223
  body.set("client_id", String(client.id));
@@ -263,7 +279,7 @@ async function createGroupConnectionOidcProvider(config, redirectUri) {
263
279
  const tokenIssuer = typeof tokenIssuerRaw === "string" ? tokenIssuerRaw.replace(/\/$/, "") : void 0;
264
280
  if (!tokenIssuer || !expectedIssuers.includes(tokenIssuer)) throw new Error(`OIDC token issuer mismatch. Received: ${tokenIssuer ?? "<missing>"}. Expected one of: ${expectedIssuers.join(", ")}`);
265
281
  if (payload.nonce !== ctx.nonce) throw new Error("OIDC nonce mismatch.");
266
- if (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== String(client.id)) throw new Error("OIDC authorized party does not match client ID.");
282
+ if (payload.azp !== void 0 && payload.azp !== String(client.id)) throw new Error("OIDC authorized party does not match client ID.");
267
283
  verifiedClaims = payload;
268
284
  verifiedProfile = normalizeProfile(payload);
269
285
  },
@@ -30,7 +30,17 @@ const DEFAULT_GROUP_CONNECTION_POLICY = {
30
30
  }
31
31
  }
32
32
  };
33
+ /** Accept a value only if it's one of the allowed options, otherwise return the fallback. */
34
+ function oneOf(value, allowed, fallback) {
35
+ return allowed.includes(value) ? value : fallback;
36
+ }
37
+ /** Parse an object whose values are string arrays (e.g. group→roleIds mapping). */
38
+ function parseStringArrayMapping(raw) {
39
+ if (typeof raw !== "object" || raw === null) return void 0;
40
+ return Object.fromEntries(Object.entries(raw).filter(([key, value]) => typeof key === "string" && Array.isArray(value)).map(([key, value]) => [key, Array.from(new Set(value.filter((item) => typeof item === "string" && item.length > 0)))]));
41
+ }
33
42
  function normalizeGroupConnectionPolicy(policy) {
43
+ const d = DEFAULT_GROUP_CONNECTION_POLICY;
34
44
  const input = asRecord(policy) ?? {};
35
45
  const accountLinking = asRecord((asRecord(input.identity) ?? {}).accountLinking) ?? {};
36
46
  const provisioning = asRecord(input.provisioning) ?? {};
@@ -41,34 +51,40 @@ function normalizeGroupConnectionPolicy(policy) {
41
51
  const groups = asRecord(provisioning.groups) ?? {};
42
52
  const roles = asRecord(provisioning.roles) ?? {};
43
53
  const extend = asRecord(input.extend) ?? void 0;
54
+ const groupsMapping = parseStringArrayMapping(groups.mapping);
55
+ const rolesMapping = parseStringArrayMapping(roles.mapping);
44
56
  return {
45
57
  version: 1,
46
58
  identity: { accountLinking: {
47
- oidc: accountLinking.oidc === "none" ? "none" : DEFAULT_GROUP_CONNECTION_POLICY.identity.accountLinking.oidc,
48
- saml: accountLinking.saml === "none" ? "none" : DEFAULT_GROUP_CONNECTION_POLICY.identity.accountLinking.saml
59
+ oidc: oneOf(accountLinking.oidc, ["none"], d.identity.accountLinking.oidc),
60
+ saml: oneOf(accountLinking.saml, ["none"], d.identity.accountLinking.saml)
49
61
  } },
50
62
  provisioning: {
51
63
  user: {
52
- createOnSignIn: typeof user.createOnSignIn === "boolean" ? user.createOnSignIn : DEFAULT_GROUP_CONNECTION_POLICY.provisioning.user.createOnSignIn,
53
- updateProfileOnLogin: user.updateProfileOnLogin === "never" || user.updateProfileOnLogin === "always" ? user.updateProfileOnLogin : DEFAULT_GROUP_CONNECTION_POLICY.provisioning.user.updateProfileOnLogin,
54
- updateProfileFromScim: user.updateProfileFromScim === "never" || user.updateProfileFromScim === "missing" ? user.updateProfileFromScim : DEFAULT_GROUP_CONNECTION_POLICY.provisioning.user.updateProfileFromScim,
55
- authority: user.authority === "sso" || user.authority === "scim" ? user.authority : DEFAULT_GROUP_CONNECTION_POLICY.provisioning.user.authority
64
+ createOnSignIn: typeof user.createOnSignIn === "boolean" ? user.createOnSignIn : d.provisioning.user.createOnSignIn,
65
+ updateProfileOnLogin: oneOf(user.updateProfileOnLogin, ["never", "always"], d.provisioning.user.updateProfileOnLogin),
66
+ updateProfileFromScim: oneOf(user.updateProfileFromScim, ["never", "missing"], d.provisioning.user.updateProfileFromScim),
67
+ authority: oneOf(user.authority, ["sso", "scim"], d.provisioning.user.authority)
56
68
  },
57
- scimReuse: { user: scimReuse.user === "none" ? "none" : DEFAULT_GROUP_CONNECTION_POLICY.provisioning.scimReuse.user },
69
+ scimReuse: { user: oneOf(scimReuse.user, ["none"], d.provisioning.scimReuse.user) },
58
70
  jit: {
59
- mode: jit.mode === "off" || jit.mode === "createUser" || jit.mode === "createUserAndMembership" ? jit.mode : DEFAULT_GROUP_CONNECTION_POLICY.provisioning.jit.mode,
60
- defaultRoleIds: Array.isArray(jit.defaultRoleIds) ? Array.from(new Set(jit.defaultRoleIds.filter((value) => typeof value === "string" && value.length > 0))) : typeof jit.defaultRole === "string" && jit.defaultRole.length > 0 ? [jit.defaultRole] : DEFAULT_GROUP_CONNECTION_POLICY.provisioning.jit.defaultRoleIds
71
+ mode: oneOf(jit.mode, [
72
+ "off",
73
+ "createUser",
74
+ "createUserAndMembership"
75
+ ], d.provisioning.jit.mode),
76
+ defaultRoleIds: Array.isArray(jit.defaultRoleIds) ? Array.from(new Set(jit.defaultRoleIds.filter((value) => typeof value === "string" && value.length > 0))) : typeof jit.defaultRole === "string" && jit.defaultRole.length > 0 ? [jit.defaultRole] : d.provisioning.jit.defaultRoleIds
61
77
  },
62
- deprovision: { mode: deprovision.mode === "hard" ? "hard" : DEFAULT_GROUP_CONNECTION_POLICY.provisioning.deprovision.mode },
78
+ deprovision: { mode: oneOf(deprovision.mode, ["hard"], d.provisioning.deprovision.mode) },
63
79
  groups: {
64
- mode: groups.mode === "sync" ? "sync" : DEFAULT_GROUP_CONNECTION_POLICY.provisioning.groups.mode,
80
+ mode: oneOf(groups.mode, ["sync"], d.provisioning.groups.mode),
65
81
  source: "protocol",
66
- ...typeof groups.mapping === "object" && groups.mapping !== null ? { mapping: Object.fromEntries(Object.entries(groups.mapping).filter(([key, value]) => typeof key === "string" && Array.isArray(value)).map(([key, value]) => [key, Array.from(new Set(value.filter((item) => typeof item === "string" && item.length > 0)))])) } : {}
82
+ ...groupsMapping ? { mapping: groupsMapping } : {}
67
83
  },
68
84
  roles: {
69
- mode: roles.mode === "map" ? "map" : DEFAULT_GROUP_CONNECTION_POLICY.provisioning.roles.mode,
85
+ mode: oneOf(roles.mode, ["map"], d.provisioning.roles.mode),
70
86
  source: "protocol",
71
- ...typeof roles.mapping === "object" && roles.mapping !== null ? { mapping: Object.fromEntries(Object.entries(roles.mapping).filter(([key, value]) => typeof key === "string" && Array.isArray(value)).map(([key, value]) => [key, Array.from(new Set(value.filter((item) => typeof item === "string" && item.length > 0)))])) } : {}
87
+ ...rolesMapping ? { mapping: rolesMapping } : {}
72
88
  }
73
89
  },
74
90
  ...extend ? { extend } : {}
@@ -8,7 +8,7 @@ function getScimConfigShape(scimConfig) {
8
8
  const convexError = (data) => new ConvexError(data);
9
9
  function createGroupScimDomain(deps) {
10
10
  const { config, requireEnv, generateRandomString, INVITE_TOKEN_ALPHABET, sha256, loadGroupPolicyOrThrow, recordGroupAuditEvent, emitGroupWebhookDeliveries } = deps;
11
- const getScimBasePath = (connectionId) => `${requireEnv("CONVEX_SITE_URL")}/api/auth/connections/${connectionId}/scim/v2`;
11
+ const getScimBasePath = (connectionId) => `${requireEnv("CONVEX_SITE_URL")}/connections/${connectionId}/scim/v2`;
12
12
  const validateScim = async (ctx, connectionId) => {
13
13
  const checks = [];
14
14
  const connection = await getGroupConnection(ctx, config.component.public, connectionId);
@@ -6,14 +6,20 @@ import { decodeBase64urlIgnorePadding, encodeBase64urlNoPadding } from "@oslojs/
6
6
  import { Constants, IdentityProvider, ServiceProvider, setSchemaValidator } from "@robelest/samlify";
7
7
 
8
8
  //#region src/server/sso/saml.ts
9
+ function formDataEntries(formData) {
10
+ return formData;
11
+ }
9
12
  const _samlifyPermissiveValidator = { validate: (_xml) => Promise.resolve("OK") };
10
13
  function ensureSamlifyValidator() {
11
14
  setSchemaValidator(_samlifyPermissiveValidator);
12
15
  }
16
+ function escapeHtmlAttribute(value) {
17
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
18
+ }
13
19
  /** @internal */
14
20
  function createSamlPostBindingResponse(opts) {
15
- const fields = [`<input type="hidden" name="${opts.parameter}" value="${opts.value.replace(/"/g, "&quot;")}" />`, opts.relayState ? `<input type="hidden" name="RelayState" value="${opts.relayState.replace(/"/g, "&quot;")}" />` : ""].join("");
16
- return new Response(`<!doctype html><html><body><form method="POST" action="${opts.endpoint}">${fields}</form><script>document.forms[0].submit();<\/script></body></html>`, {
21
+ const fields = [`<input type="hidden" name="${opts.parameter}" value="${escapeHtmlAttribute(opts.value)}" />`, opts.relayState ? `<input type="hidden" name="RelayState" value="${escapeHtmlAttribute(opts.relayState)}" />` : ""].join("");
22
+ return new Response(`<!doctype html><html><body><form method="POST" action="${escapeHtmlAttribute(opts.endpoint)}">${fields}</form><script>document.forms[0].submit();<\/script></body></html>`, {
17
23
  status: 200,
18
24
  headers: { "Content-Type": "text/html; charset=utf-8" }
19
25
  });
@@ -62,9 +68,7 @@ async function readRequestBody(request) {
62
68
  if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
63
69
  const form = await request.formData();
64
70
  const body = {};
65
- form.forEach((value, key) => {
66
- body[key] = typeof value === "string" ? value : value.name;
67
- });
71
+ for (const [key, value] of formDataEntries(form)) body[key] = typeof value === "string" ? value : value.name;
68
72
  return body;
69
73
  }
70
74
  return {};
@@ -89,6 +93,7 @@ function getSamlSecurityConfig(config) {
89
93
  /** @internal */
90
94
  function parseSamlIdpMetadata(metadata) {
91
95
  const source = typeof metadata === "string" ? metadata : String(metadata);
96
+ if (/<!DOCTYPE|<!ENTITY/i.test(source)) throw new Error("SAML metadata must not contain DTD or entity declarations.");
92
97
  const entityId = source.match(/<[^>]*EntityDescriptor\b[^>]*\bentityID="([^"]+)"/i)?.[1] ?? null;
93
98
  if (!entityId) throw new Error("SAML metadata is missing EntityDescriptor@entityID.");
94
99
  const parseAttributes = (source$1) => {
@@ -259,11 +264,13 @@ function verifySamlTimeWindow(notBefore, notOnOrAfter, clockSkewSeconds) {
259
264
  const drift = clockSkewSeconds * 1e3;
260
265
  if (notBefore) {
261
266
  const notBeforeTime = new Date(notBefore).getTime();
262
- if (Number.isFinite(notBeforeTime) && now < notBeforeTime - drift) throw new Error("SAML assertion is not yet valid.");
267
+ if (!Number.isFinite(notBeforeTime)) throw new Error("SAML assertion has an invalid NotBefore timestamp.");
268
+ if (now < notBeforeTime - drift) throw new Error("SAML assertion is not yet valid.");
263
269
  }
264
270
  if (notOnOrAfter) {
265
271
  const notOnOrAfterTime = new Date(notOnOrAfter).getTime();
266
- if (Number.isFinite(notOnOrAfterTime) && now >= notOnOrAfterTime + drift) throw new Error("SAML assertion has expired.");
272
+ if (!Number.isFinite(notOnOrAfterTime)) throw new Error("SAML assertion has an invalid NotOnOrAfter timestamp.");
273
+ if (now >= notOnOrAfterTime + drift) throw new Error("SAML assertion has expired.");
267
274
  }
268
275
  }
269
276
  /** @internal */
@@ -273,7 +280,7 @@ function enforceGroupConnectionSamlSecurity(opts) {
273
280
  const conditions = opts.extract?.conditions;
274
281
  if (security.requireSignedAssertions === true && typeof opts.extract?.signature?.signatureAlgorithm !== "string") throw new Error("SAML assertion must be signed.");
275
282
  if (security.requireTimestamps === true) {
276
- if (!conditions?.notBefore && !conditions?.notOnOrAfter) throw new Error("SAML assertion missing required timestamp conditions.");
283
+ if (!conditions?.notBefore || !conditions?.notOnOrAfter) throw new Error("SAML assertion missing required timestamp conditions.");
277
284
  }
278
285
  if (conditions?.notBefore || conditions?.notOnOrAfter) verifySamlTimeWindow(conditions.notBefore, conditions.notOnOrAfter, security.clockSkewSeconds ?? 300);
279
286
  }
@@ -377,10 +384,12 @@ async function parseGroupConnectionSamlLogoutMessage(opts) {
377
384
  relayState: httpRequest.relayState
378
385
  });
379
386
  const parsedRequest = httpRequest.hasSamlRequest ? await runtime.sp.parseLogoutRequest(runtime.idp, httpRequest.binding, toSamlHttpRequest(httpRequest)) : void 0;
387
+ const parsedResponse = httpRequest.hasSamlResponse ? await runtime.sp.parseLogoutResponse(runtime.idp, httpRequest.binding, toSamlHttpRequest(httpRequest)) : void 0;
380
388
  return {
381
389
  ...httpRequest,
382
390
  runtime,
383
- parsedRequest
391
+ parsedRequest,
392
+ parsedResponse
384
393
  };
385
394
  }
386
395
  /** @internal */
@@ -3,8 +3,10 @@ import { SCIM_GROUP_SCHEMA_ID, SCIM_USER_SCHEMA_ID } from "./shared.js";
3
3
  //#region src/server/sso/scim.ts
4
4
  /** @internal */
5
5
  function parseScimPath(pathname) {
6
- const [api, auth, connections, connectionId, protocol, version, ...rest] = pathname.split("/").filter(Boolean);
7
- if (api !== "api" || auth !== "auth" || connections !== "connections" || !connectionId || connectionId === "setup" || protocol !== "scim" || version !== "v2") return {
6
+ const parts = pathname.split("/").filter(Boolean);
7
+ const connectionsIndex = parts.lastIndexOf("connections");
8
+ const [connectionId, protocol, version, ...rest] = connectionsIndex >= 0 ? parts.slice(connectionsIndex + 1) : [];
9
+ if (connectionsIndex < 0 || !connectionId || connectionId === "setup" || protocol !== "scim" || version !== "v2" || rest.length > 2) return {
8
10
  connectionId: "",
9
11
  resource: "",
10
12
  resourceId: void 0
@@ -17,8 +19,14 @@ function parseScimPath(pathname) {
17
19
  }
18
20
  /** @internal */
19
21
  function parseScimListRequest(url) {
20
- const startIndex = Math.max(1, Number(url.searchParams.get("startIndex") ?? "1"));
21
- const count = Math.min(100, Math.max(1, Number(url.searchParams.get("count") ?? "100")));
22
+ const rawStartIndex = url.searchParams.get("startIndex") ?? "1";
23
+ const rawCount = url.searchParams.get("count") ?? "100";
24
+ const parsedStartIndex = Number(rawStartIndex);
25
+ const parsedCount = Number(rawCount);
26
+ if (!Number.isInteger(parsedStartIndex) || parsedStartIndex < 1) throw new Error("Invalid SCIM pagination.");
27
+ if (!Number.isInteger(parsedCount) || parsedCount < 0) throw new Error("Invalid SCIM pagination.");
28
+ const startIndex = parsedStartIndex;
29
+ const count = Math.min(100, parsedCount);
22
30
  const filterParam = url.searchParams.get("filter");
23
31
  return {
24
32
  startIndex,
@@ -23,21 +23,21 @@ function groupSamlProviderId(connectionId) {
23
23
  function getGroupSamlUrls(opts) {
24
24
  const root = opts.rootUrl.replace(/\/$/, "");
25
25
  return {
26
- metadataUrl: `${root}/api/auth/connections/${opts.source.id}/saml/metadata`,
27
- acsUrl: `${root}/api/auth/connections/${opts.source.id}/saml/acs`,
28
- sloUrl: `${root}/api/auth/connections/${opts.source.id}/saml/slo`
26
+ metadataUrl: `${root}/connections/${opts.source.id}/saml/metadata`,
27
+ acsUrl: `${root}/connections/${opts.source.id}/saml/acs`,
28
+ sloUrl: `${root}/connections/${opts.source.id}/saml/slo`
29
29
  };
30
30
  }
31
31
  /** @internal */
32
32
  function getGroupOidcUrls(opts) {
33
33
  const root = opts.rootUrl.replace(/\/$/, "");
34
34
  const callbackUrl = (() => {
35
- if (typeof opts.sharedRedirectURI !== "string") return `${root}/api/auth/connections/${opts.connectionId}/oidc/callback`;
35
+ if (typeof opts.sharedRedirectURI !== "string") return `${root}/connections/${opts.connectionId}/oidc/callback`;
36
36
  if (/^https?:\/\//.test(opts.sharedRedirectURI)) return opts.sharedRedirectURI;
37
37
  return `${root}${opts.sharedRedirectURI.startsWith("/") ? "" : "/"}${opts.sharedRedirectURI}`;
38
38
  })();
39
39
  return {
40
- signInUrl: `${root}/api/auth/connections/${opts.connectionId}/oidc/signin`,
40
+ signInUrl: `${root}/connections/${opts.connectionId}/oidc/signin`,
41
41
  callbackUrl
42
42
  };
43
43
  }
@@ -43,12 +43,15 @@ async function buildAuthIdentityAttributes(ctx, config, args) {
43
43
  if (Object.keys(attributes).length > 0) attributes["auth.identity.mode"] = mode;
44
44
  return attributes;
45
45
  }
46
+ /** @internal */
46
47
  async function buildRefreshIdentityAttributes(ctx, config, args) {
47
48
  return await buildAuthIdentityAttributes(ctx, config, args);
48
49
  }
50
+ /** @internal */
49
51
  async function buildSignInIdentityAttributes(ctx, config, args) {
50
52
  return await buildAuthIdentityAttributes(ctx, config, args);
51
53
  }
54
+ /** @internal */
52
55
  async function buildSignOutIdentityAttributes(ctx, config, args) {
53
56
  return await buildAuthIdentityAttributes(ctx, config, args);
54
57
  }
@@ -1,5 +1,5 @@
1
1
  import { generateRandomString } from "./random.js";
2
- import { requireEnv } from "./env.js";
2
+ import { envOptionalString, readConfigSync, requireEnv } from "./env.js";
3
3
  import { withSpan } from "./utils/span.js";
4
4
  import { SignJWT, importPKCS8 } from "jose";
5
5
 
@@ -34,9 +34,17 @@ const getPrivateKey = () => {
34
34
  return cachedPrivateKeyPromise;
35
35
  };
36
36
  const getIssuer = () => {
37
- if (cachedIssuer === null) cachedIssuer = requireEnv("CONVEX_SITE_URL");
37
+ if (cachedIssuer === null) cachedIssuer = appendAuthPrefix(requireEnv("CONVEX_SITE_URL"), readConfigSync(envOptionalString("CONVEX_AUTH_HTTP_PREFIX")) ?? "/auth");
38
38
  return cachedIssuer;
39
39
  };
40
+ function appendAuthPrefix(siteUrl, prefix) {
41
+ return `${siteUrl.replace(/\/$/, "")}${normalizeAuthPrefix(prefix)}`;
42
+ }
43
+ function normalizeAuthPrefix(prefix) {
44
+ const trimmed = prefix.trim();
45
+ if (trimmed === "" || trimmed === "/") return "";
46
+ return `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
47
+ }
40
48
  try {
41
49
  getPrivateKey().catch(() => {});
42
50
  } catch {}
@@ -12,22 +12,20 @@ import { createTOTPKeyURI, verifyTOTPWithGracePeriod } from "@oslojs/otp";
12
12
  /**
13
13
  * Server-side TOTP ceremony logic for two-factor authentication.
14
14
  *
15
- * Handles the three phases of the TOTP flow:
16
- * 1. setup — generate a TOTP secret and `otpauth://` URI for enrollment
17
- * 2. confirm verify the first code from the authenticator app
18
- * 3. verify verify a TOTP code during sign-in (2FA challenge)
15
+ * Two single-word flows:
16
+ *
17
+ * - `setup` generate a TOTP secret and `otpauth://` URI for enrollment.
18
+ * - `verify` consume a TOTP code. Auto-detects:
19
+ * - first-time enrollment confirmation (caller passes `totpId`), or
20
+ * - 2FA sign-in challenge (no `totpId`).
19
21
  */
20
- const TOTP_FLOWS = [
21
- "setup",
22
- "confirm",
23
- "verify"
24
- ];
22
+ const TOTP_FLOWS = ["setup", "verify"];
25
23
  const convexError = (code, message) => toConvexError(authFlowError(code, message));
26
24
  const asConvexError = (error, code, message) => error instanceof ConvexError ? error : error instanceof Error ? toConvexError(authFlowError(code, error.message || message)) : convexError(code, message);
27
25
  function resolveTotpFlow(params) {
28
26
  const flow = params.flow;
29
27
  if (typeof flow === "string" && TOTP_FLOWS.includes(flow)) return flow;
30
- throw convexError("TOTP_MISSING_FLOW", "Missing `flow` parameter. Expected one of: setup, confirm, verify");
28
+ throw convexError("TOTP_MISSING_FLOW", "Missing `flow` parameter. Expected one of: " + TOTP_FLOWS.join(", "));
31
29
  }
32
30
  function requireTotpVerifier(verifier) {
33
31
  if (verifier != null) return verifier;
@@ -37,30 +35,25 @@ function requireTotpCode(params) {
37
35
  if (typeof params.code === "string") return params.code;
38
36
  throw convexError("TOTP_MISSING_CODE", "Missing TOTP code.");
39
37
  }
40
- function requireTotpId(params) {
41
- if (typeof params.totpId === "string") return params.totpId;
42
- throw convexError("TOTP_MISSING_ID", "Missing TOTP enrollment ID.");
43
- }
44
38
  function resolveTotpDispatch(params, verifier) {
45
- const flow = resolveTotpFlow(params);
46
- if (flow === "setup") return {
39
+ if (resolveTotpFlow(params) === "setup") return {
47
40
  flow: "setup",
48
41
  params
49
42
  };
50
- if (flow === "confirm") {
51
- const resolvedVerifier$1 = requireTotpVerifier(verifier);
52
- return {
53
- flow: "confirm",
54
- code: requireTotpCode(params),
55
- totpId: requireTotpId(params),
56
- verifier: resolvedVerifier$1
57
- };
58
- }
59
43
  const resolvedVerifier = requireTotpVerifier(verifier);
44
+ const code = requireTotpCode(params);
45
+ if (typeof params.totpId === "string" && params.totpId.length > 0) return {
46
+ flow: "verify",
47
+ code,
48
+ totpId: params.totpId,
49
+ verifier: resolvedVerifier,
50
+ intent: "enrollment"
51
+ };
60
52
  return {
61
53
  flow: "verify",
62
- code: requireTotpCode(params),
63
- verifier: resolvedVerifier
54
+ code,
55
+ verifier: resolvedVerifier,
56
+ intent: "challenge"
64
57
  };
65
58
  }
66
59
  async function requireAuthenticatedUserId(ctx) {
@@ -76,7 +69,7 @@ async function requireAuthenticatedUserId(ctx) {
76
69
  /** @internal */
77
70
  const handleTotp = async (ctx, provider, args) => {
78
71
  const dispatch = resolveTotpDispatch(args.params ?? {}, args.verifier);
79
- const handler = {
72
+ const flowHandlers = {
80
73
  setup: async () => {
81
74
  const { params: setupParams } = dispatch;
82
75
  const userId = await requireAuthenticatedUserId(ctx);
@@ -94,17 +87,6 @@ const handleTotp = async (ctx, provider, args) => {
94
87
  }
95
88
  const uri = createTOTPKeyURI(provider.options.issuer, accountName, secret, provider.options.period, provider.options.digits);
96
89
  const base32Secret = encodeBase32LowerCaseNoPadding(secret);
97
- let verifier;
98
- try {
99
- verifier = await callVerifier(ctx, JSON.stringify({
100
- secret: Array.from(secret),
101
- userId,
102
- digits: provider.options.digits,
103
- period: provider.options.period
104
- }));
105
- } catch (error) {
106
- throw asConvexError(error, "INTERNAL_ERROR", `TOTP setup failed: ${String(error)}`);
107
- }
108
90
  let totpId;
109
91
  try {
110
92
  totpId = await mutateTotpInsert(ctx, {
@@ -119,6 +101,19 @@ const handleTotp = async (ctx, provider, args) => {
119
101
  } catch (error) {
120
102
  throw asConvexError(error, "INTERNAL_ERROR", `TOTP setup failed: ${String(error)}`);
121
103
  }
104
+ let verifier;
105
+ try {
106
+ verifier = await callVerifier(ctx, JSON.stringify({
107
+ purpose: "totp.setup",
108
+ secret: Array.from(secret),
109
+ userId,
110
+ totpId,
111
+ digits: provider.options.digits,
112
+ period: provider.options.period
113
+ }));
114
+ } catch (error) {
115
+ throw asConvexError(error, "INTERNAL_ERROR", `TOTP setup failed: ${String(error)}`);
116
+ }
122
117
  return {
123
118
  kind: "totpSetup",
124
119
  totpSetup: {
@@ -129,69 +124,101 @@ const handleTotp = async (ctx, provider, args) => {
129
124
  verifier
130
125
  };
131
126
  },
132
- confirm: async () => {
133
- const { code, totpId, verifier } = dispatch;
134
- const userId = await requireAuthenticatedUserId(ctx);
135
- let doc;
136
- try {
137
- doc = await queryTotpById(ctx, totpId);
138
- } catch {
139
- throw convexError("TOTP_NOT_FOUND", "TOTP enrollment not found.");
140
- }
141
- if (doc === null) throw convexError("TOTP_NOT_FOUND", "TOTP enrollment not found.");
142
- if (doc.verified) throw convexError("TOTP_ALREADY_VERIFIED", "TOTP enrollment is already verified.");
143
- if (!verifyTOTPWithGracePeriod(new Uint8Array(doc.secret), provider.options.period, provider.options.digits, code, 30)) throw convexError("TOTP_INVALID_CODE", "Invalid TOTP code.");
144
- let signInResult;
145
- try {
146
- await mutateTotpMarkVerified(ctx, totpId, Date.now());
147
- await mutateVerifierDelete(ctx, verifier);
148
- signInResult = await callSignIn(ctx, {
149
- userId,
150
- generateTokens: true
151
- });
152
- } catch (error) {
153
- throw asConvexError(error, "INTERNAL_ERROR", String(error));
154
- }
155
- return {
156
- kind: "signedIn",
157
- session: signInResult
158
- };
159
- },
160
127
  verify: async () => {
161
- const { code, verifier } = dispatch;
162
- let doc;
163
- try {
164
- doc = await queryVerifierById(ctx, verifier);
165
- } catch {
166
- throw convexError("TOTP_INVALID_VERIFIER", "Invalid or expired TOTP verifier.");
167
- }
168
- if (doc === null) throw convexError("TOTP_INVALID_VERIFIER", "Invalid or expired TOTP verifier.");
169
- const userId = JSON.parse(doc.signature).userId;
170
- let totp;
171
- try {
172
- totp = await queryTotpVerifiedByUserId(ctx, userId);
173
- } catch {
174
- throw convexError("TOTP_NO_ENROLLMENT", "No verified TOTP enrollment found.");
175
- }
176
- if (totp === null) throw convexError("TOTP_NO_ENROLLMENT", "No verified TOTP enrollment found.");
177
- if (!verifyTOTPWithGracePeriod(new Uint8Array(totp.secret), totp.period, totp.digits, code, 30)) throw convexError("TOTP_INVALID_CODE", "Invalid TOTP code.");
178
- let signInResult;
179
- try {
180
- await mutateTotpUpdateLastUsed(ctx, totp._id, Date.now());
181
- await mutateVerifierDelete(ctx, verifier);
182
- signInResult = await callSignIn(ctx, {
183
- userId,
184
- generateTokens: true
185
- });
186
- } catch (error) {
187
- throw asConvexError(error, "INTERNAL_ERROR", String(error));
188
- }
189
- return {
190
- kind: "signedIn",
191
- session: signInResult
192
- };
128
+ if (dispatch.flow !== "verify") throw convexError("TOTP_MISSING_FLOW", `Unexpected dispatch: ${dispatch.flow}`);
129
+ if (dispatch.intent === "enrollment") return await confirmEnrollment(dispatch.code, dispatch.totpId, dispatch.verifier);
130
+ return await verifyChallenge(dispatch.code, dispatch.verifier);
131
+ }
132
+ };
133
+ /**
134
+ * `verify` with `totpId`: completes a first-time enrollment after `setup`.
135
+ * Marks the TOTP factor as verified and signs the user in.
136
+ */
137
+ async function confirmEnrollment(code, totpId, verifier) {
138
+ const userId = await requireAuthenticatedUserId(ctx);
139
+ let verifierDoc;
140
+ try {
141
+ verifierDoc = await queryVerifierById(ctx, verifier);
142
+ } catch {
143
+ throw convexError("TOTP_INVALID_VERIFIER", "Invalid or expired TOTP verifier.");
144
+ }
145
+ if (verifierDoc === null) throw convexError("TOTP_INVALID_VERIFIER", "Invalid or expired TOTP verifier.");
146
+ let verifierData;
147
+ try {
148
+ verifierData = JSON.parse(verifierDoc.signature);
149
+ } catch {
150
+ throw convexError("TOTP_INVALID_VERIFIER", "Invalid or expired TOTP verifier.");
151
+ }
152
+ if (verifierData.purpose !== "totp.setup" || verifierData.userId !== userId || verifierData.totpId !== totpId) throw convexError("TOTP_INVALID_VERIFIER", "Invalid or expired TOTP verifier.");
153
+ let doc;
154
+ try {
155
+ doc = await queryTotpById(ctx, totpId);
156
+ } catch {
157
+ throw convexError("TOTP_NOT_FOUND", "TOTP enrollment not found.");
158
+ }
159
+ if (doc === null) throw convexError("TOTP_NOT_FOUND", "TOTP enrollment not found.");
160
+ if (doc.userId !== userId) throw convexError("TOTP_NOT_FOUND", "TOTP enrollment not found.");
161
+ if (doc.verified) throw convexError("TOTP_ALREADY_VERIFIED", "TOTP enrollment is already verified.");
162
+ if (!verifyTOTPWithGracePeriod(new Uint8Array(doc.secret), provider.options.period, provider.options.digits, code, 30)) throw convexError("TOTP_INVALID_CODE", "Invalid TOTP code.");
163
+ let signInResult;
164
+ try {
165
+ await mutateTotpMarkVerified(ctx, totpId, Date.now());
166
+ await mutateVerifierDelete(ctx, verifier);
167
+ signInResult = await callSignIn(ctx, {
168
+ userId,
169
+ generateTokens: true
170
+ });
171
+ } catch (error) {
172
+ throw asConvexError(error, "INTERNAL_ERROR", String(error));
173
+ }
174
+ await ctx.auth.config.callbacks?.after?.(ctx, {
175
+ kind: "totpEnrolled",
176
+ userId,
177
+ totpId
178
+ });
179
+ return {
180
+ kind: "signedIn",
181
+ session: signInResult
182
+ };
183
+ }
184
+ /**
185
+ * `verify` without `totpId`: completes a 2FA challenge during sign-in.
186
+ * Looks up the user's verified TOTP factor, validates the code, signs in.
187
+ */
188
+ async function verifyChallenge(code, verifier) {
189
+ let doc;
190
+ try {
191
+ doc = await queryVerifierById(ctx, verifier);
192
+ } catch {
193
+ throw convexError("TOTP_INVALID_VERIFIER", "Invalid or expired TOTP verifier.");
194
+ }
195
+ if (doc === null) throw convexError("TOTP_INVALID_VERIFIER", "Invalid or expired TOTP verifier.");
196
+ const userId = JSON.parse(doc.signature).userId;
197
+ let totp;
198
+ try {
199
+ totp = await queryTotpVerifiedByUserId(ctx, userId);
200
+ } catch {
201
+ throw convexError("TOTP_NO_ENROLLMENT", "No verified TOTP enrollment found.");
193
202
  }
194
- }[dispatch.flow];
203
+ if (totp === null) throw convexError("TOTP_NO_ENROLLMENT", "No verified TOTP enrollment found.");
204
+ if (!verifyTOTPWithGracePeriod(new Uint8Array(totp.secret), totp.period, totp.digits, code, 30)) throw convexError("TOTP_INVALID_CODE", "Invalid TOTP code.");
205
+ let signInResult;
206
+ try {
207
+ await mutateTotpUpdateLastUsed(ctx, totp._id, Date.now());
208
+ await mutateVerifierDelete(ctx, verifier);
209
+ signInResult = await callSignIn(ctx, {
210
+ userId,
211
+ generateTokens: true
212
+ });
213
+ } catch (error) {
214
+ throw asConvexError(error, "INTERNAL_ERROR", String(error));
215
+ }
216
+ return {
217
+ kind: "signedIn",
218
+ session: signInResult
219
+ };
220
+ }
221
+ const handler = flowHandlers[dispatch.flow];
195
222
  if (!handler) throw convexError("TOTP_MISSING_FLOW", `Unknown TOTP flow: ${dispatch.flow}`);
196
223
  return handler();
197
224
  };