@_mustachio/openauth 0.13.2 → 0.14.0

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 (88) hide show
  1. package/dist/esm/client.js +61 -62
  2. package/dist/esm/domain/authorize.js +21 -3
  3. package/dist/esm/domain/callback.js +30 -21
  4. package/dist/esm/domain/client-credentials.js +16 -0
  5. package/dist/esm/domain/method-dispatch.js +1 -0
  6. package/dist/esm/domain/method-route.js +12 -0
  7. package/dist/esm/domain/mount.js +29 -0
  8. package/dist/esm/domain/refresh.js +16 -5
  9. package/dist/esm/domain/register.js +8 -2
  10. package/dist/esm/domain/state-envelope.js +19 -0
  11. package/dist/esm/domain/subject.js +35 -0
  12. package/dist/esm/domain/token.js +15 -0
  13. package/dist/esm/http/handlers/method-route.js +8 -3
  14. package/dist/esm/http/handlers/token.js +2 -0
  15. package/dist/esm/http/middleware/tenant.js +5 -20
  16. package/dist/esm/index.js +4 -0
  17. package/dist/esm/methods/code.js +9 -8
  18. package/dist/esm/methods/oauth2-generic.js +2 -1
  19. package/dist/esm/methods/passkey.js +3 -2
  20. package/dist/esm/methods/password.js +9 -8
  21. package/dist/esm/methods/saml-sp/metadata.js +2 -1
  22. package/dist/types/client.d.ts +61 -40
  23. package/dist/types/client.d.ts.map +1 -1
  24. package/dist/types/domain/authorize.d.ts.map +1 -1
  25. package/dist/types/domain/callback.d.ts +6 -5
  26. package/dist/types/domain/callback.d.ts.map +1 -1
  27. package/dist/types/domain/client-credentials.d.ts +3 -1
  28. package/dist/types/domain/client-credentials.d.ts.map +1 -1
  29. package/dist/types/domain/method-dispatch.d.ts +5 -0
  30. package/dist/types/domain/method-dispatch.d.ts.map +1 -1
  31. package/dist/types/domain/method-route.d.ts +6 -0
  32. package/dist/types/domain/method-route.d.ts.map +1 -1
  33. package/dist/types/domain/mount.d.ts +90 -0
  34. package/dist/types/domain/mount.d.ts.map +1 -0
  35. package/dist/types/domain/refresh.d.ts.map +1 -1
  36. package/dist/types/domain/register.d.ts.map +1 -1
  37. package/dist/types/domain/state-envelope.d.ts +22 -0
  38. package/dist/types/domain/state-envelope.d.ts.map +1 -1
  39. package/dist/types/domain/subject.d.ts +48 -0
  40. package/dist/types/domain/subject.d.ts.map +1 -0
  41. package/dist/types/domain/token.d.ts +7 -1
  42. package/dist/types/domain/token.d.ts.map +1 -1
  43. package/dist/types/http/context.d.ts +3 -0
  44. package/dist/types/http/context.d.ts.map +1 -1
  45. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  46. package/dist/types/http/handlers/token.d.ts.map +1 -1
  47. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  48. package/dist/types/http/schemas/revocation.d.ts +4 -4
  49. package/dist/types/http/schemas/token.d.ts +12 -12
  50. package/dist/types/index.d.ts +17 -1
  51. package/dist/types/index.d.ts.map +1 -1
  52. package/dist/types/methods/code.d.ts.map +1 -1
  53. package/dist/types/methods/oauth2-generic.d.ts.map +1 -1
  54. package/dist/types/methods/passkey.d.ts.map +1 -1
  55. package/dist/types/methods/password.d.ts.map +1 -1
  56. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -1
  57. package/dist/types/ports/audit-log.d.ts +25 -1
  58. package/dist/types/ports/audit-log.d.ts.map +1 -1
  59. package/dist/types/types/idp.d.ts +49 -39
  60. package/dist/types/types/idp.d.ts.map +1 -1
  61. package/dist/types/types/method.d.ts +11 -0
  62. package/dist/types/types/method.d.ts.map +1 -1
  63. package/package.json +2 -1
  64. package/src/client.ts +145 -129
  65. package/src/domain/authorize.ts +25 -4
  66. package/src/domain/callback.ts +40 -36
  67. package/src/domain/client-credentials.ts +20 -1
  68. package/src/domain/method-dispatch.ts +6 -0
  69. package/src/domain/method-route.ts +18 -0
  70. package/src/domain/mount.ts +113 -0
  71. package/src/domain/refresh.ts +39 -10
  72. package/src/domain/register.ts +27 -8
  73. package/src/domain/state-envelope.ts +40 -0
  74. package/src/domain/subject.ts +103 -0
  75. package/src/domain/token.ts +25 -1
  76. package/src/http/context.ts +3 -0
  77. package/src/http/handlers/method-route.ts +9 -5
  78. package/src/http/handlers/token.ts +2 -0
  79. package/src/http/middleware/tenant.ts +5 -24
  80. package/src/index.ts +18 -2
  81. package/src/methods/code.ts +15 -6
  82. package/src/methods/oauth2-generic.ts +6 -1
  83. package/src/methods/passkey.ts +6 -2
  84. package/src/methods/password.ts +19 -4
  85. package/src/methods/saml-sp/metadata.ts +5 -1
  86. package/src/ports/audit-log.ts +26 -1
  87. package/src/types/idp.ts +49 -42
  88. package/src/types/method.ts +11 -0
@@ -31,6 +31,7 @@ import type {
31
31
  MethodContext,
32
32
  MethodResult,
33
33
  } from "../types/method"
34
+ import { mountedPath } from "../domain/mount"
34
35
  import { renderForm } from "../ui/forms"
35
36
 
36
37
  export type CodeProperties = {
@@ -120,12 +121,12 @@ export function codeMethod(
120
121
  kind,
121
122
  type: "code",
122
123
  routes: {
123
- "GET /authorize": async (_ctx) => ({
124
+ "GET /authorize": async (ctx) => ({
124
125
  kind: "challenge",
125
126
  response: htmlResponse(
126
127
  renderForm({
127
128
  title: titleRequest,
128
- action: `/m/${id}/send`,
129
+ action: mountedPath(ctx.issuerUrl, `/m/${id}/send`),
129
130
  fields: destinationField(destinationKind),
130
131
  submit: "Send code",
131
132
  }),
@@ -169,7 +170,7 @@ async function handleSend(
169
170
  response: htmlResponse(
170
171
  renderForm({
171
172
  title: titleRequest,
172
- action: `/m/${methodId}/send`,
173
+ action: mountedPath(ctx.issuerUrl, `/m/${methodId}/send`),
173
174
  fields: destinationField(destinationKind),
174
175
  submit: "Send code",
175
176
  error: "Please enter a valid destination.",
@@ -193,7 +194,7 @@ async function handleSend(
193
194
  response: htmlResponse(
194
195
  renderForm({
195
196
  title: titleVerify,
196
- action: `/m/${methodId}/verify`,
197
+ action: mountedPath(ctx.issuerUrl, `/m/${methodId}/verify`),
197
198
  fields: [
198
199
  {
199
200
  name: "code",
@@ -238,7 +239,13 @@ async function handleVerify(
238
239
  const form = await safeForm(ctx.request)
239
240
  const parsed = verifyBody.safeParse(form)
240
241
  if (!parsed.success) {
241
- return verifyError(methodId, state, titleVerify, "Please enter the code.")
242
+ return verifyError(
243
+ ctx.issuerUrl,
244
+ methodId,
245
+ state,
246
+ titleVerify,
247
+ "Please enter the code.",
248
+ )
242
249
  }
243
250
 
244
251
  const submittedHash = base64url.encode(
@@ -246,6 +253,7 @@ async function handleVerify(
246
253
  )
247
254
  if (!timingSafeEqualStr(submittedHash, state.codeHash)) {
248
255
  return verifyError(
256
+ ctx.issuerUrl,
249
257
  methodId,
250
258
  { ...state, attempts: state.attempts + 1 },
251
259
  titleVerify,
@@ -261,6 +269,7 @@ async function handleVerify(
261
269
  }
262
270
 
263
271
  function verifyError(
272
+ issuerUrl: string,
264
273
  methodId: string,
265
274
  next: CodeState,
266
275
  titleVerify: string,
@@ -271,7 +280,7 @@ function verifyError(
271
280
  response: htmlResponse(
272
281
  renderForm({
273
282
  title: titleVerify,
274
- action: `/m/${methodId}/verify`,
283
+ action: mountedPath(issuerUrl, `/m/${methodId}/verify`),
275
284
  fields: [
276
285
  {
277
286
  name: "code",
@@ -37,6 +37,7 @@ import { createRemoteJWKSet, jwtVerify } from "jose"
37
37
  */
38
38
  type IdTokenClaims = Record<string, unknown>
39
39
 
40
+ import { mountedPath } from "../domain/mount"
40
41
  import { authError } from "../types/error"
41
42
  import type { AuthMethod, MethodContext, MethodResult } from "../types/method"
42
43
  import type { MethodType } from "../types/tenant"
@@ -212,7 +213,11 @@ async function exchangeAndSucceed(
212
213
  error: authError.internalError("flow missing on callback"),
213
214
  }
214
215
  }
215
- const callbackUrl = `${new URL(ctx.request.url).protocol}//${ctx.flow.callbackHost}${ctx.flow.callbackPath}`
216
+ // The `redirect_uri` on the token exchange must be byte-identical to the
217
+ // one sent on the authorize redirect (OAuth 2.1 §4.1.3). `flow.callbackPath`
218
+ // is the *inbound* route, which a path-mounted proxy has stripped the mount
219
+ // prefix from, so re-apply it here rather than replaying the stored path.
220
+ const callbackUrl = `${new URL(ctx.request.url).protocol}//${ctx.flow.callbackHost}${mountedPath(ctx.issuerUrl, ctx.flow.callbackPath)}`
216
221
 
217
222
  const body = new URLSearchParams({
218
223
  grant_type: "authorization_code",
@@ -32,6 +32,7 @@ import type {
32
32
  MethodContext,
33
33
  MethodResult,
34
34
  } from "../types/method"
35
+ import { mountedPath } from "../domain/mount"
35
36
  import { renderForm } from "../ui/forms"
36
37
 
37
38
  export type PasskeyProperties = {
@@ -146,12 +147,15 @@ export function passkeyMethod(
146
147
  kind,
147
148
  type: "passkey",
148
149
  routes: {
149
- "GET /authorize": async () => ({
150
+ "GET /authorize": async (ctx) => ({
150
151
  kind: "challenge",
151
152
  response: htmlResponse(
152
153
  renderForm({
153
154
  title: settings.title,
154
- action: `/m/${id}/authenticate-options`,
155
+ action: mountedPath(
156
+ ctx.issuerUrl,
157
+ `/m/${id}/authenticate-options`,
158
+ ),
155
159
  fields: [
156
160
  {
157
161
  name: "username",
@@ -26,6 +26,7 @@ import type {
26
26
  MethodContext,
27
27
  MethodResult,
28
28
  } from "../types/method"
29
+ import { mountedPath } from "../domain/mount"
29
30
  import { renderForm } from "../ui/forms"
30
31
 
31
32
  export type PasswordProperties = {
@@ -140,7 +141,7 @@ async function renderLogin(
140
141
  ): Promise<MethodResult<PasswordProperties, PasswordState>> {
141
142
  const body = renderForm({
142
143
  title,
143
- action: `/m/${methodId}/login`,
144
+ action: mountedPath(ctx.issuerUrl, `/m/${methodId}/login`),
144
145
  fields: [
145
146
  {
146
147
  name: "email",
@@ -180,6 +181,7 @@ async function handleLogin(
180
181
  const parsed = loginBodySchema.safeParse(form)
181
182
  if (!parsed.success) {
182
183
  return reLoginWithError(
184
+ ctx.issuerUrl,
183
185
  methodId,
184
186
  title,
185
187
  "Please enter your email and password.",
@@ -188,12 +190,22 @@ async function handleLogin(
188
190
 
189
191
  const user = await users.findByEmail(parsed.data.email, ctx.tenant.id)
190
192
  if (!user) {
191
- return reLoginWithError(methodId, title, "Invalid email or password.")
193
+ return reLoginWithError(
194
+ ctx.issuerUrl,
195
+ methodId,
196
+ title,
197
+ "Invalid email or password.",
198
+ )
192
199
  }
193
200
 
194
201
  const verified = await hasher.verify(parsed.data.password, user.passwordHash)
195
202
  if (!verified) {
196
- return reLoginWithError(methodId, title, "Invalid email or password.")
203
+ return reLoginWithError(
204
+ ctx.issuerUrl,
205
+ methodId,
206
+ title,
207
+ "Invalid email or password.",
208
+ )
197
209
  }
198
210
 
199
211
  return {
@@ -218,6 +230,7 @@ async function handleRegister(
218
230
  const parsed = registerBodySchema.safeParse(form)
219
231
  if (!parsed.success) {
220
232
  return reLoginWithError(
233
+ ctx.issuerUrl,
221
234
  methodId,
222
235
  title,
223
236
  "Password must be at least 8 characters and email must be valid.",
@@ -227,6 +240,7 @@ async function handleRegister(
227
240
  const existing = await users.findByEmail(parsed.data.email, ctx.tenant.id)
228
241
  if (existing) {
229
242
  return reLoginWithError(
243
+ ctx.issuerUrl,
230
244
  methodId,
231
245
  title,
232
246
  "An account with that email already exists.",
@@ -250,6 +264,7 @@ async function handleRegister(
250
264
  }
251
265
 
252
266
  function reLoginWithError(
267
+ issuerUrl: string,
253
268
  methodId: string,
254
269
  title: string,
255
270
  error: string,
@@ -259,7 +274,7 @@ function reLoginWithError(
259
274
  response: new Response(
260
275
  renderForm({
261
276
  title,
262
- action: `/m/${methodId}/login`,
277
+ action: mountedPath(issuerUrl, `/m/${methodId}/login`),
263
278
  fields: [
264
279
  {
265
280
  name: "email",
@@ -22,6 +22,7 @@
22
22
  * `WantAssertionsSigned` are both read from config, so they state
23
23
  * actual runtime behaviour rather than a hardcoded assumption.
24
24
  */
25
+ import { mountedPath } from "../../domain/mount"
25
26
  import { authError } from "../../types/error"
26
27
  import type { MethodContext, MethodResult } from "../../types/method"
27
28
 
@@ -184,7 +185,10 @@ export async function buildSpMetadata(
184
185
  // served (idp.sloUrl set ⇒ /sls is in publicRoutes). Derived from the
185
186
  // same dispatch input as the ACS so it cannot drift.
186
187
  const cb = new URL(ctx.dispatch.callbackUrl)
187
- const slsUrl = `${cb.protocol}//${cb.host}/m/${methodId}/sls`
188
+ const slsUrl = `${cb.protocol}//${cb.host}${mountedPath(
189
+ ctx.dispatch.issuerUrl,
190
+ `/m/${methodId}/sls`,
191
+ )}`
188
192
  const xml = buildSpMetadataXml({
189
193
  spEntityId,
190
194
  acsUrl: ctx.dispatch.callbackUrl,
@@ -28,13 +28,24 @@ export type AuditEvent =
28
28
  flowId: string
29
29
  }
30
30
  | {
31
+ /**
32
+ * A method authenticated the end user and an authorization code was
33
+ * minted. Completes the `authorize_started` / `authorize_failed`
34
+ * pair, which shipped without it.
35
+ */
31
36
  kind: "authorize_succeeded"
32
37
  tenantId: TenantId
33
38
  clientId: string
34
39
  methodId: string
35
40
  methodKind: string
36
41
  flowId: string
37
- subjectId: string
42
+ /**
43
+ * The **upstream** identifier the method returned. Deliberately not
44
+ * `subjectId`: the OIDC subject is derived from the host's
45
+ * `success()` claim at `/token`, which has not run yet. Correlate
46
+ * with `token_issued.subjectId` via `clientId` + `flowId`.
47
+ */
48
+ providerSubject: string
38
49
  }
39
50
  | {
40
51
  kind: "authorize_failed"
@@ -162,6 +173,20 @@ export type AuditEvent =
162
173
  /** Zod error path string. Never the raw config blob. */
163
174
  errorPath: string
164
175
  }
176
+ | {
177
+ /**
178
+ * `IdPOptions.success` returned a claim that violates the host's
179
+ * own `subjects` schema. A deployment fault, not RP behaviour —
180
+ * token issuance is refused. Carries paths, never values.
181
+ */
182
+ kind: "invalid_subject_claim"
183
+ tenantId: TenantId
184
+ clientId: string
185
+ subjectType: string
186
+ reason: "unknown-type" | "invalid-properties"
187
+ /** Standard Schema issue path, or the declared type list. */
188
+ detail: string
189
+ }
165
190
  | {
166
191
  kind: "unknown_method_kind"
167
192
  tenantId: TenantId
package/src/types/idp.ts CHANGED
@@ -46,30 +46,6 @@ export type SuccessMapInput = {
46
46
  context: Record<string, unknown> | null
47
47
  }
48
48
 
49
- /**
50
- * Optional observation hook payload — fires after the subject claim has
51
- * already been minted. **Does not** influence the issued subject; use it
52
- * for audit, analytics, side effects only.
53
- */
54
- export type SuccessEvent = SuccessMapInput & {
55
- /** The final subject claim that became the JWT `sub`. */
56
- claim: SubjectClaim
57
- }
58
-
59
- /**
60
- * Optional observation hook payload — fires on a failed auth attempt.
61
- * Carries enough id information for operators to find the offending flow
62
- * / config row without leaking secrets.
63
- */
64
- export type FailureEvent = {
65
- tenantId: TenantId | null
66
- clientId: string | null
67
- methodId?: string
68
- methodKind?: string
69
- flowId?: string
70
- error: AuthError
71
- }
72
-
73
49
  /**
74
50
  * Input to the optional `IdPOptions.onLogout` hook.
75
51
  *
@@ -217,19 +193,41 @@ export type RegisterClientResponse = {
217
193
  /**
218
194
  * Optional Dynamic Client Registration hook. Hosts that want to expose
219
195
  * RFC 7591 client provisioning supply this; the framework validates the
220
- * wire format, then defers persistence to the host. If absent, the
221
- * `/register` endpoint returns `invalid_request` so RPs receive a clear
222
- * "not enabled" signal rather than a 404.
196
+ * wire format and mints credentials, then defers **persistence** to the
197
+ * host. If absent, the `/register` endpoint returns `invalid_request` so
198
+ * RPs receive a clear "not enabled" signal rather than a 404.
199
+ *
200
+ * The library owns credential generation — entropy, hashing, and the
201
+ * `ClientConfig` discriminated union, which requires `pkceRequired: true`
202
+ * as a literal on public clients and a `secretHash` on confidential ones.
203
+ * Those are protocol and security concerns, and making every host
204
+ * reimplement them is how they get done wrong. The host owns the table:
205
+ * write `client` through your own `ConfigStore` and return it.
206
+ *
207
+ * Before 0.14.0 this hook received only `{ tenant, request }` and the
208
+ * framework discarded what it had generated, so hosts had to mint their
209
+ * own — contradicting both this doc comment and `ARCHITECTURE.md`.
223
210
  *
224
- * The hook receives the parsed request, the resolved tenant, and the
225
- * plaintext client secret (if any) the framework minted — hosts hash it
226
- * with `hashClientSecret` before storing on `ClientConfig.secretHash`,
227
- * then return the final `ClientConfig` along with the secret in the
228
- * `RegisterClientResponse` so the RP can record it.
211
+ * Return the config you actually persisted. Adjusting it first is fine
212
+ * (narrowing `scopes`, substituting your own `id`); if you replace `id`
213
+ * or `secretHash`, return the matching plaintext as `secret` so the RP
214
+ * receives something that works.
229
215
  */
230
216
  export type RegisterClient = (input: {
231
217
  tenant: TenantContext
232
218
  request: RegisterClientRequest
219
+ /**
220
+ * Framework-minted `ClientConfig`, ready to persist as-is. Public
221
+ * clients carry `pkceRequired: true`; confidential clients carry
222
+ * `secretHash` for `secret` below.
223
+ */
224
+ client: ClientConfig
225
+ /**
226
+ * Plaintext secret matching `client.secretHash`. Present only for
227
+ * confidential clients. Return it in the result so the RP can record
228
+ * it — this is the only time it exists.
229
+ */
230
+ secret?: string
233
231
  }) => Promise<Result<{ client: ClientConfig; secret?: string }, AuthError>>
234
232
 
235
233
  /**
@@ -255,7 +253,22 @@ export type IdPOptions = {
255
253
  */
256
254
  resolveTenant: (req: Request) => Promise<Result<TenantId, AuthError>>
257
255
 
258
- /** Opt-in for partitioned callback hosts (recovery mechanism #2). */
256
+ /**
257
+ * Opt-in for partitioned callback hosts (recovery mechanism #2).
258
+ *
259
+ * Returns the hostname a given tenant's callbacks arrive on, so the
260
+ * tenant is recoverable from the `Host` header before the state
261
+ * envelope is verified.
262
+ *
263
+ * **The issuer's mount prefix still applies to the returned host.** If
264
+ * `issuerUrl` is `https://example.com/idp`, a tenant host of
265
+ * `acme.example.com` produces `https://acme.example.com/idp/cb/<id>`.
266
+ * This option varies the *authority* of one deployment — those hosts
267
+ * are served by this same service behind the same proxy, so they share
268
+ * its mount. A deployment needing partitioned hosts mounted differently
269
+ * from the issuer is describing two mounts, which one `issuerUrl`
270
+ * cannot express; a second option must not be added to paper over it.
271
+ */
259
272
  callbackHostFor?: (tenantId: TenantId) => string
260
273
 
261
274
  /**
@@ -313,12 +326,6 @@ export type IdPOptions = {
313
326
 
314
327
  theme?: ThemeConfig
315
328
 
316
- hooks?: {
317
- /** Observation only — does NOT influence the subject. */
318
- onSuccess?: (event: SuccessEvent) => Promise<void>
319
- onFailure?: (event: FailureEvent) => Promise<void>
320
- }
321
-
322
329
  /**
323
330
  * Optional hook fired when an upstream provider signals that a
324
331
  * federated session ended — SAML front-channel Single Logout today.
@@ -327,9 +334,9 @@ export type IdPOptions = {
327
334
  * OIDC subject (if any) whose library-issued tokens to revoke. See
328
335
  * the `LogoutEventInput` / `LogoutHookResult` type docs.
329
336
  *
330
- * Unlike `hooks.onSuccess`/`onFailure` (observation only) this hook
331
- * **influences** library behaviour — its return drives token
332
- * revocation — so it sits at the top level alongside `success`.
337
+ * Unlike `AuditLog` (observation only) this hook **influences**
338
+ * library behaviour — its return drives token revocation — so it sits
339
+ * at the top level alongside `success`.
333
340
  *
334
341
  * Absent ⇒ the library still verifies the logout, emits a
335
342
  * `session_logout` audit event, and returns the protocol
@@ -113,6 +113,17 @@ export type MethodHandler<P, S> = (
113
113
  export type MethodContext<S = unknown> = {
114
114
  /** The raw Web Fetch `Request`. Web standard, not Hono `Context`. */
115
115
  request: Request
116
+ /**
117
+ * The issuer URL of this IdP, always present.
118
+ *
119
+ * Distinct from `dispatch.issuerUrl`, which is only populated for
120
+ * `GET /authorize`. Methods that render their own URLs — a form action
121
+ * re-rendered on a validation error, say — need it on every route, and
122
+ * must build those URLs with `mountedPath` so they carry the
123
+ * deployment's mount prefix. `ctx.request.url` is not a substitute: a
124
+ * proxy-stripped inbound URL has already lost that prefix.
125
+ */
126
+ issuerUrl: string
116
127
  /** Path within the method's mount, e.g. `"/callback"` (without the `/<id>` prefix). */
117
128
  subPath: string
118
129
  tenant: TenantContext