@_mustachio/openauth 0.10.0 → 0.12.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.
- package/dist/esm/adapters/d1/index.js +18 -9
- package/dist/esm/adapters/d1/migrations.js +7 -2
- package/dist/esm/adapters/d1/session-store.js +39 -0
- package/dist/esm/adapters/d1/session.js +2 -2
- package/dist/esm/adapters/durable-object/index.js +4 -0
- package/dist/esm/adapters/durable-object/session-store.js +42 -0
- package/dist/esm/adapters/dynamo/index.js +24 -7
- package/dist/esm/adapters/dynamo/session-store.js +49 -0
- package/dist/esm/adapters/kms/index.js +6 -2
- package/dist/esm/adapters/kv/index.js +6 -2
- package/dist/esm/adapters/memory/index.js +24 -7
- package/dist/esm/adapters/memory/session-store.js +45 -0
- package/dist/esm/adapters/memory/token-store.js +13 -0
- package/dist/esm/adapters/postgres/executor.js +2 -2
- package/dist/esm/adapters/postgres/index.js +33 -10
- package/dist/esm/adapters/postgres/migrations.js +8 -2
- package/dist/esm/adapters/postgres/session-store.js +42 -0
- package/dist/esm/client.js +35 -28
- package/dist/esm/domain/authorize.js +7 -2
- package/dist/esm/domain/callback.js +124 -7
- package/dist/esm/domain/client-auth.js +2 -2
- package/dist/esm/domain/crypto.js +14 -14
- package/dist/esm/domain/discovery.js +46 -4
- package/dist/esm/domain/dpop.js +122 -0
- package/dist/esm/domain/id-token.js +94 -0
- package/dist/esm/domain/introspect.js +5 -1
- package/dist/esm/domain/jwt.js +30 -2
- package/dist/esm/domain/logout.js +75 -0
- package/dist/esm/domain/method-dispatch.js +27 -1
- package/dist/esm/domain/method-route.js +83 -1
- package/dist/esm/domain/par.js +51 -0
- package/dist/esm/domain/pkce.js +2 -2
- package/dist/esm/domain/refresh.js +12 -1
- package/dist/esm/domain/register.js +74 -0
- package/dist/esm/domain/revoke.js +2 -2
- package/dist/esm/domain/state-envelope.js +2 -2
- package/dist/esm/domain/token-exchange.js +1 -0
- package/dist/esm/domain/token.js +53 -12
- package/dist/esm/domain/userinfo.js +38 -3
- package/dist/esm/error.js +8 -8
- package/dist/esm/http/cookies.js +19 -2
- package/dist/esm/http/errors.js +4 -4
- package/dist/esm/http/handlers/authorize.js +92 -21
- package/dist/esm/http/handlers/callback.js +7 -1
- package/dist/esm/http/handlers/end-session.js +64 -0
- package/dist/esm/http/handlers/metadata.js +7 -4
- package/dist/esm/http/handlers/method-route.js +47 -2
- package/dist/esm/http/handlers/par.js +53 -0
- package/dist/esm/http/handlers/register.js +46 -0
- package/dist/esm/http/handlers/revocation.js +2 -2
- package/dist/esm/http/handlers/token.js +36 -6
- package/dist/esm/http/handlers/userinfo.js +35 -29
- package/dist/esm/http/middleware/tenant.js +6 -6
- package/dist/esm/http/router.js +10 -0
- package/dist/esm/http/schemas/authorize.js +8 -2
- package/dist/esm/http/schemas/end-session.js +13 -0
- package/dist/esm/http/schemas/par.js +9 -0
- package/dist/esm/http/schemas/register.js +16 -0
- package/dist/esm/http/schemas/revocation.js +2 -2
- package/dist/esm/http/schemas/token.js +7 -7
- package/dist/esm/index.js +40 -34
- package/dist/esm/methods/oauth2-factory.js +2 -2
- package/dist/esm/methods/password-hash.js +2 -2
- package/dist/esm/methods/providers/index.js +30 -14
- package/dist/esm/methods/saml-sp/acs.js +208 -0
- package/dist/esm/methods/saml-sp/attributes.js +75 -0
- package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
- package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
- package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
- package/dist/esm/methods/saml-sp/factory.js +70 -0
- package/dist/esm/methods/saml-sp/index.js +7 -0
- package/dist/esm/methods/saml-sp/metadata.js +69 -0
- package/dist/esm/methods/saml-sp/method.js +29 -0
- package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
- package/dist/esm/methods/saml-sp/saml-instance.js +49 -0
- package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
- package/dist/esm/methods/saml-sp/sls.js +154 -0
- package/dist/esm/methods/saml-sp/types.js +0 -0
- package/dist/esm/types/error.js +5 -0
- package/dist/esm/types/result.js +3 -3
- package/dist/esm/ui/forms.js +3 -3
- package/dist/esm/ui/index.js +8 -5
- package/dist/esm/ui/picker.js +2 -2
- package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
- package/dist/types/adapters/d1/session-store.d.ts +3 -0
- package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
- package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
- package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
- package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
- package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
- package/dist/types/adapters/memory/session-store.d.ts +6 -1
- package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
- package/dist/types/adapters/memory/token-store.d.ts +1 -0
- package/dist/types/adapters/memory/token-store.d.ts.map +1 -1
- package/dist/types/adapters/postgres/migrations.d.ts +1 -1
- package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
- package/dist/types/adapters/postgres/session-store.d.ts +3 -0
- package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
- package/dist/types/client.d.ts +19 -0
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/domain/authorize.d.ts.map +1 -1
- package/dist/types/domain/callback.d.ts +16 -1
- package/dist/types/domain/callback.d.ts.map +1 -1
- package/dist/types/domain/client-credentials.d.ts +2 -0
- package/dist/types/domain/client-credentials.d.ts.map +1 -1
- package/dist/types/domain/discovery.d.ts +37 -0
- package/dist/types/domain/discovery.d.ts.map +1 -1
- package/dist/types/domain/dpop.d.ts +61 -0
- package/dist/types/domain/dpop.d.ts.map +1 -0
- package/dist/types/domain/id-token.d.ts +83 -0
- package/dist/types/domain/id-token.d.ts.map +1 -0
- package/dist/types/domain/introspect.d.ts +8 -0
- package/dist/types/domain/introspect.d.ts.map +1 -1
- package/dist/types/domain/jwt.d.ts +27 -1
- package/dist/types/domain/jwt.d.ts.map +1 -1
- package/dist/types/domain/logout.d.ts +69 -0
- package/dist/types/domain/logout.d.ts.map +1 -0
- package/dist/types/domain/method-dispatch.d.ts.map +1 -1
- package/dist/types/domain/method-route.d.ts +57 -1
- package/dist/types/domain/method-route.d.ts.map +1 -1
- package/dist/types/domain/par.d.ts +62 -0
- package/dist/types/domain/par.d.ts.map +1 -0
- package/dist/types/domain/refresh.d.ts +9 -0
- package/dist/types/domain/refresh.d.ts.map +1 -1
- package/dist/types/domain/register.d.ts +33 -0
- package/dist/types/domain/register.d.ts.map +1 -0
- package/dist/types/domain/token-exchange.d.ts +2 -0
- package/dist/types/domain/token-exchange.d.ts.map +1 -1
- package/dist/types/domain/token.d.ts +41 -1
- package/dist/types/domain/token.d.ts.map +1 -1
- package/dist/types/domain/userinfo.d.ts +44 -5
- package/dist/types/domain/userinfo.d.ts.map +1 -1
- package/dist/types/http/context.d.ts +6 -1
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/cookies.d.ts +9 -1
- package/dist/types/http/cookies.d.ts.map +1 -1
- package/dist/types/http/handlers/authorize.d.ts.map +1 -1
- package/dist/types/http/handlers/callback.d.ts.map +1 -1
- package/dist/types/http/handlers/end-session.d.ts +3 -0
- package/dist/types/http/handlers/end-session.d.ts.map +1 -0
- package/dist/types/http/handlers/metadata.d.ts +1 -1
- package/dist/types/http/handlers/metadata.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/http/handlers/par.d.ts +3 -0
- package/dist/types/http/handlers/par.d.ts.map +1 -0
- package/dist/types/http/handlers/register.d.ts +3 -0
- package/dist/types/http/handlers/register.d.ts.map +1 -0
- package/dist/types/http/handlers/token.d.ts.map +1 -1
- package/dist/types/http/handlers/userinfo.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/http/schemas/authorize.d.ts +40 -0
- package/dist/types/http/schemas/authorize.d.ts.map +1 -1
- package/dist/types/http/schemas/end-session.d.ts +31 -0
- package/dist/types/http/schemas/end-session.d.ts.map +1 -0
- package/dist/types/http/schemas/par.d.ts +20 -0
- package/dist/types/http/schemas/par.d.ts.map +1 -0
- package/dist/types/http/schemas/register.d.ts +42 -0
- package/dist/types/http/schemas/register.d.ts.map +1 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/acs.d.ts +4 -0
- package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/attributes.d.ts +40 -0
- package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/factory.d.ts +704 -0
- package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/index.d.ts +24 -0
- package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/metadata.d.ts +42 -0
- package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/method.d.ts +17 -0
- package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts +81 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/sls.d.ts +4 -0
- package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/types.d.ts +204 -0
- package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
- package/dist/types/ports/audit-log.d.ts +40 -0
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/ports/session-store.d.ts +50 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/ports/token-store.d.ts +12 -0
- package/dist/types/ports/token-store.d.ts.map +1 -1
- package/dist/types/types/authorization.d.ts +23 -0
- package/dist/types/types/authorization.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +9 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/flow.d.ts +10 -0
- package/dist/types/types/flow.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +162 -1
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/method.d.ts +109 -0
- package/dist/types/types/method.d.ts.map +1 -1
- package/dist/types/types/tenant.d.ts +37 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/dist/types/types/token.d.ts +128 -0
- package/dist/types/types/token.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/adapters/d1/migrations.ts +5 -0
- package/src/adapters/d1/session-store.ts +68 -0
- package/src/adapters/durable-object/session-store.ts +61 -0
- package/src/adapters/dynamo/session-store.ts +63 -0
- package/src/adapters/memory/session-store.ts +83 -1
- package/src/adapters/memory/token-store.ts +16 -0
- package/src/adapters/postgres/migrations.ts +6 -0
- package/src/adapters/postgres/session-store.ts +69 -0
- package/src/client.ts +32 -0
- package/src/domain/authorize.ts +9 -0
- package/src/domain/callback.ts +220 -7
- package/src/domain/client-credentials.ts +2 -0
- package/src/domain/discovery.ts +80 -2
- package/src/domain/dpop.ts +293 -0
- package/src/domain/id-token.ts +213 -0
- package/src/domain/introspect.ts +10 -0
- package/src/domain/jwt.ts +80 -1
- package/src/domain/logout.ts +180 -0
- package/src/domain/method-dispatch.ts +53 -1
- package/src/domain/method-route.ts +195 -1
- package/src/domain/par.ts +141 -0
- package/src/domain/refresh.ts +41 -0
- package/src/domain/register.ts +168 -0
- package/src/domain/token-exchange.ts +11 -0
- package/src/domain/token.ts +134 -8
- package/src/domain/userinfo.ts +121 -6
- package/src/http/context.ts +10 -0
- package/src/http/cookies.ts +19 -1
- package/src/http/handlers/authorize.ts +146 -24
- package/src/http/handlers/callback.ts +9 -1
- package/src/http/handlers/end-session.ts +106 -0
- package/src/http/handlers/metadata.ts +7 -2
- package/src/http/handlers/method-route.ts +75 -2
- package/src/http/handlers/par.ts +84 -0
- package/src/http/handlers/register.ts +70 -0
- package/src/http/handlers/token.ts +56 -0
- package/src/http/handlers/userinfo.ts +62 -38
- package/src/http/router.ts +19 -0
- package/src/http/schemas/authorize.ts +27 -0
- package/src/http/schemas/end-session.ts +23 -0
- package/src/http/schemas/par.ts +23 -0
- package/src/http/schemas/register.ts +29 -0
- package/src/index.ts +21 -1
- package/src/methods/saml-sp/acs.ts +399 -0
- package/src/methods/saml-sp/attributes.ts +137 -0
- package/src/methods/saml-sp/authnrequest.ts +128 -0
- package/src/methods/saml-sp/cache-provider.ts +76 -0
- package/src/methods/saml-sp/cert-rotation.ts +34 -0
- package/src/methods/saml-sp/factory.ts +118 -0
- package/src/methods/saml-sp/index.ts +34 -0
- package/src/methods/saml-sp/metadata.ts +204 -0
- package/src/methods/saml-sp/method.ts +65 -0
- package/src/methods/saml-sp/parse-idp-metadata.ts +164 -0
- package/src/methods/saml-sp/saml-instance.ts +173 -0
- package/src/methods/saml-sp/slo-initiate.ts +177 -0
- package/src/methods/saml-sp/sls.ts +306 -0
- package/src/methods/saml-sp/types.ts +203 -0
- package/src/ports/CONSISTENCY.md +77 -21
- package/src/ports/audit-log.ts +42 -0
- package/src/ports/session-store.ts +61 -0
- package/src/ports/token-store.ts +13 -0
- package/src/types/authorization.ts +25 -0
- package/src/types/error.ts +23 -0
- package/src/types/flow.ts +10 -0
- package/src/types/idp.ts +170 -0
- package/src/types/method.ts +110 -0
- package/src/types/tenant.ts +37 -0
- package/src/types/token.ts +135 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic Client Registration (RFC 7591).
|
|
3
|
+
*
|
|
4
|
+
* The host owns persistence — this module validates the wire request,
|
|
5
|
+
* mints a fresh `client_id` (and `client_secret` for confidential
|
|
6
|
+
* clients), invokes the host's `registerClient` hook with a structured
|
|
7
|
+
* `ClientConfig`, and builds the §3.2.1 response.
|
|
8
|
+
*
|
|
9
|
+
* Per RFC 7591:
|
|
10
|
+
* - §2: registration request body is JSON, fields include
|
|
11
|
+
* `client_name`, `redirect_uris`, `grant_types`, `response_types`,
|
|
12
|
+
* `token_endpoint_auth_method`, `scope`, etc.
|
|
13
|
+
* - §3.2.1: response is JSON with `client_id`, optional
|
|
14
|
+
* `client_secret`, `client_id_issued_at`, optional
|
|
15
|
+
* `client_secret_expires_at`, and the registered metadata.
|
|
16
|
+
* - §3.2.2: structural problems → `invalid_client_metadata` /
|
|
17
|
+
* `invalid_redirect_uri`. We collapse to `invalid_request` for
|
|
18
|
+
* consistency with the rest of the library's error mapping.
|
|
19
|
+
*/
|
|
20
|
+
import { authError, type AuthError } from "../types/error"
|
|
21
|
+
import type {
|
|
22
|
+
RegisterClient,
|
|
23
|
+
RegisterClientRequest,
|
|
24
|
+
RegisterClientResponse,
|
|
25
|
+
} from "../types/idp"
|
|
26
|
+
import type { Result } from "../types/result"
|
|
27
|
+
import { err, isErr, ok } from "../types/result"
|
|
28
|
+
import type { TenantContext } from "../types/tenant"
|
|
29
|
+
|
|
30
|
+
import { randomId, randomToken } from "./crypto"
|
|
31
|
+
import { hashClientSecret } from "./token"
|
|
32
|
+
|
|
33
|
+
export type RegisterDeps = {
|
|
34
|
+
registerClient?: RegisterClient
|
|
35
|
+
clock: () => number
|
|
36
|
+
/** Test override for deterministic client_id. */
|
|
37
|
+
newClientId?: () => string
|
|
38
|
+
/** Test override for deterministic client_secret. */
|
|
39
|
+
newClientSecret?: () => string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function registerNewClient(
|
|
43
|
+
request: RegisterClientRequest,
|
|
44
|
+
tenant: TenantContext,
|
|
45
|
+
deps: RegisterDeps,
|
|
46
|
+
): Promise<Result<RegisterClientResponse, AuthError>> {
|
|
47
|
+
if (!deps.registerClient) {
|
|
48
|
+
return err(
|
|
49
|
+
authError.invalidRequest(
|
|
50
|
+
"dynamic client registration is not enabled on this deployment",
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// RFC 7591 §2 — redirect_uris is REQUIRED for any client that uses a
|
|
56
|
+
// redirect-based grant. We accept the field as required at the wire
|
|
57
|
+
// level (schema enforces) and validate non-empty here.
|
|
58
|
+
if (
|
|
59
|
+
!Array.isArray(request.redirect_uris) ||
|
|
60
|
+
request.redirect_uris.length === 0
|
|
61
|
+
) {
|
|
62
|
+
return err(
|
|
63
|
+
authError.invalidRequest(
|
|
64
|
+
"redirect_uris must be a non-empty array",
|
|
65
|
+
"redirect_uris",
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
for (const uri of request.redirect_uris) {
|
|
70
|
+
try {
|
|
71
|
+
// Reject relative / opaque URIs eagerly so the host gets a clean
|
|
72
|
+
// structural rejection instead of a runtime surprise at /authorize.
|
|
73
|
+
// RFC 7591 §2 leaves URI scheme to grant-type semantics; we follow
|
|
74
|
+
// the OAuth 2.1 BCP and require absolute URIs.
|
|
75
|
+
void new URL(uri)
|
|
76
|
+
} catch {
|
|
77
|
+
return err(
|
|
78
|
+
authError.invalidRequest(
|
|
79
|
+
`redirect_uri "${uri}" is not a valid absolute URI`,
|
|
80
|
+
"redirect_uris",
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const authMethod = request.token_endpoint_auth_method ?? "client_secret_basic"
|
|
87
|
+
const isPublic = authMethod === "none"
|
|
88
|
+
const clientId = (deps.newClientId ?? randomId)()
|
|
89
|
+
const grantTypes = request.grant_types ?? ["authorization_code"]
|
|
90
|
+
const responseTypes = request.response_types ?? ["code"]
|
|
91
|
+
const scopes = request.scope
|
|
92
|
+
? request.scope.split(/\s+/).filter(Boolean)
|
|
93
|
+
: ["openid"]
|
|
94
|
+
|
|
95
|
+
let secret: string | undefined
|
|
96
|
+
let secretHash: string | undefined
|
|
97
|
+
if (!isPublic) {
|
|
98
|
+
secret = (deps.newClientSecret ?? randomToken)()
|
|
99
|
+
secretHash = await hashClientSecret(secret)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const clientPartial = {
|
|
103
|
+
id: clientId,
|
|
104
|
+
name: request.client_name ?? clientId,
|
|
105
|
+
redirectUris: request.redirect_uris,
|
|
106
|
+
// Default scopes overlap with what the IdP advertises; hosts may
|
|
107
|
+
// override during the hook call before persisting.
|
|
108
|
+
scopes,
|
|
109
|
+
grantTypes: grantTypes.filter(
|
|
110
|
+
(g): g is "authorization_code" | "refresh_token" | "client_credentials" =>
|
|
111
|
+
g === "authorization_code" ||
|
|
112
|
+
g === "refresh_token" ||
|
|
113
|
+
g === "client_credentials",
|
|
114
|
+
),
|
|
115
|
+
...(request.post_logout_redirect_uris !== undefined
|
|
116
|
+
? { postLogoutRedirectUris: request.post_logout_redirect_uris }
|
|
117
|
+
: {}),
|
|
118
|
+
...(request.sector_identifier_uri !== undefined
|
|
119
|
+
? { sectorIdentifier: request.sector_identifier_uri }
|
|
120
|
+
: {}),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const clientConfig = isPublic
|
|
124
|
+
? {
|
|
125
|
+
...clientPartial,
|
|
126
|
+
type: "public" as const,
|
|
127
|
+
pkceRequired: true as const,
|
|
128
|
+
}
|
|
129
|
+
: {
|
|
130
|
+
...clientPartial,
|
|
131
|
+
type: "confidential" as const,
|
|
132
|
+
secretHash: secretHash!,
|
|
133
|
+
pkceRequired: true,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const hookResult = await deps.registerClient({
|
|
137
|
+
tenant,
|
|
138
|
+
request,
|
|
139
|
+
})
|
|
140
|
+
// The hook may either:
|
|
141
|
+
// - persist `clientConfig` as-is (most common) and return it,
|
|
142
|
+
// - synthesize its own (host-owned id generation, custom scopes) and
|
|
143
|
+
// return that — in which case the secret we minted is moot.
|
|
144
|
+
// We trust the hook's returned client + secret as authoritative.
|
|
145
|
+
void clientConfig
|
|
146
|
+
|
|
147
|
+
if (isErr(hookResult)) return err(hookResult.error)
|
|
148
|
+
const persisted = hookResult.value.client
|
|
149
|
+
const persistedSecret = hookResult.value.secret
|
|
150
|
+
|
|
151
|
+
const issuedAt = Math.floor(deps.clock() / 1000)
|
|
152
|
+
return ok({
|
|
153
|
+
client_id: persisted.id,
|
|
154
|
+
...(persistedSecret !== undefined
|
|
155
|
+
? { client_secret: persistedSecret }
|
|
156
|
+
: {}),
|
|
157
|
+
client_id_issued_at: issuedAt,
|
|
158
|
+
// RFC 7591 §3.2.1: `0` = no expiry.
|
|
159
|
+
client_secret_expires_at: 0,
|
|
160
|
+
redirect_uris: persisted.redirectUris,
|
|
161
|
+
grant_types: persisted.grantTypes,
|
|
162
|
+
response_types: responseTypes,
|
|
163
|
+
token_endpoint_auth_method: authMethod,
|
|
164
|
+
...(request.client_name !== undefined
|
|
165
|
+
? { client_name: request.client_name }
|
|
166
|
+
: {}),
|
|
167
|
+
})
|
|
168
|
+
}
|
|
@@ -74,6 +74,8 @@ export type ExchangeTokenDeps = {
|
|
|
74
74
|
issuerUrl: string
|
|
75
75
|
clock: () => number
|
|
76
76
|
newRefreshFamily?: () => string
|
|
77
|
+
/** See `IdPOptions.customScopeClaims`. Forwarded to `mintTokens`. */
|
|
78
|
+
customScopeClaims?: Record<string, ReadonlyArray<string>>
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
export async function exchangeToken(
|
|
@@ -203,6 +205,15 @@ export async function exchangeToken(
|
|
|
203
205
|
methodId: subjectClaims.mid ?? "token_exchange",
|
|
204
206
|
methodKind: subjectClaims.mkind ?? "token_exchange",
|
|
205
207
|
scopes: requestedScopes,
|
|
208
|
+
// Carry the original end-user auth_time across the audience swap.
|
|
209
|
+
// Per OIDC Core §12, switching audience is NOT a re-authentication
|
|
210
|
+
// — the user logged in once, and downstream id_token consumers rely
|
|
211
|
+
// on auth_time being stable to detect actual re-auth events. When
|
|
212
|
+
// absent (subject was a client_credentials / m2m token), mintTokens
|
|
213
|
+
// correctly skips id_token issuance.
|
|
214
|
+
...(subjectClaims.auth_time !== undefined
|
|
215
|
+
? { authTime: subjectClaims.auth_time }
|
|
216
|
+
: {}),
|
|
206
217
|
...(subjectClaims.aud !== undefined &&
|
|
207
218
|
subjectClaims.aud !== callerClient.id
|
|
208
219
|
? { audience: subjectClaims.aud }
|
package/src/domain/token.ts
CHANGED
|
@@ -47,7 +47,8 @@ import {
|
|
|
47
47
|
sha256,
|
|
48
48
|
utf8,
|
|
49
49
|
} from "./crypto"
|
|
50
|
-
import {
|
|
50
|
+
import { buildIdTokenClaims, shouldIssueIdToken } from "./id-token"
|
|
51
|
+
import { signAccessToken, signIdToken } from "./jwt"
|
|
51
52
|
import { validatePkce } from "./pkce"
|
|
52
53
|
|
|
53
54
|
/**
|
|
@@ -88,6 +89,13 @@ export type TokenAuthCodeRequest = {
|
|
|
88
89
|
clientSecret?: string
|
|
89
90
|
/** Required if the original `/authorize` request had a `code_challenge`. */
|
|
90
91
|
codeVerifier?: string
|
|
92
|
+
/**
|
|
93
|
+
* RFC 9449 §6 — the JWK thumbprint of the presented DPoP proof. Set by
|
|
94
|
+
* the HTTP layer after verifying the `DPoP:` header against this
|
|
95
|
+
* request's actual method + URI. The domain binds the issued access
|
|
96
|
+
* token to this thumbprint via `cnf.jkt`.
|
|
97
|
+
*/
|
|
98
|
+
dpopJkt?: string
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
export type ExchangeCodeDeps = {
|
|
@@ -102,6 +110,11 @@ export type ExchangeCodeDeps = {
|
|
|
102
110
|
newRefreshToken?: () => string
|
|
103
111
|
/** Test override. */
|
|
104
112
|
newRefreshFamily?: () => string
|
|
113
|
+
/**
|
|
114
|
+
* Host-supplied vendor scope → claim-names map. Forwarded to `mintTokens`
|
|
115
|
+
* for id_token + /userinfo scope-gating. See `IdPOptions.customScopeClaims`.
|
|
116
|
+
*/
|
|
117
|
+
customScopeClaims?: Record<string, ReadonlyArray<string>>
|
|
105
118
|
}
|
|
106
119
|
|
|
107
120
|
export async function exchangeCode(
|
|
@@ -141,6 +154,17 @@ export async function exchangeCode(
|
|
|
141
154
|
const authResult = await verifyClientCredentials(client, req.clientSecret)
|
|
142
155
|
if (authResult) return err(authResult)
|
|
143
156
|
|
|
157
|
+
// RFC 9449 §5.2 — if the client is configured to require DPoP, a
|
|
158
|
+
// bearer-only request (no DPoP header → no `dpopJkt` threaded in) is
|
|
159
|
+
// refused with `invalid_dpop_proof` before any token is minted.
|
|
160
|
+
if (client.dpopRequired && req.dpopJkt === undefined) {
|
|
161
|
+
return err(
|
|
162
|
+
authError.invalidDpopProof(
|
|
163
|
+
`client "${client.id}" requires DPoP-bound tokens`,
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
144
168
|
// 4. Redirect URI binding.
|
|
145
169
|
if (payload.appRedirectUri !== req.redirectUri) {
|
|
146
170
|
return err(authError.invalidGrant("redirect_uri mismatch with auth code"))
|
|
@@ -198,11 +222,14 @@ export async function exchangeCode(
|
|
|
198
222
|
}
|
|
199
223
|
}
|
|
200
224
|
|
|
201
|
-
// 8. Mint access + refresh.
|
|
225
|
+
// 8. Mint access + refresh (+ id_token if `openid` scope was granted).
|
|
202
226
|
const minted = await mintTokens({
|
|
203
227
|
tenant,
|
|
204
228
|
claim,
|
|
205
|
-
payload
|
|
229
|
+
payload: {
|
|
230
|
+
...payload,
|
|
231
|
+
...(req.dpopJkt !== undefined ? { dpopJkt: req.dpopJkt } : {}),
|
|
232
|
+
},
|
|
206
233
|
deps,
|
|
207
234
|
family: (deps.newRefreshFamily ?? randomId)(),
|
|
208
235
|
})
|
|
@@ -219,7 +246,30 @@ export async function mintTokens(args: {
|
|
|
219
246
|
payload: Pick<
|
|
220
247
|
CodePayload,
|
|
221
248
|
"tenantId" | "clientId" | "methodId" | "methodKind" | "scopes" | "audience"
|
|
222
|
-
>
|
|
249
|
+
> & {
|
|
250
|
+
/**
|
|
251
|
+
* Present on `authorization_code` and `refresh_token` grants where an
|
|
252
|
+
* end-user actually authenticated. Absent on `client_credentials`
|
|
253
|
+
* (no end-user) — and absent means no `id_token` is emitted even if
|
|
254
|
+
* the requested scopes nominally include `openid`.
|
|
255
|
+
*/
|
|
256
|
+
authTime?: number
|
|
257
|
+
/** RP's OIDC `nonce` from `/authorize`, when present. */
|
|
258
|
+
appNonce?: string
|
|
259
|
+
/**
|
|
260
|
+
* DPoP key thumbprint (RFC 9449 §6.1). When present, the access
|
|
261
|
+
* token's `cnf.jkt` claim is set and `token_type` flips to `"DPoP"`;
|
|
262
|
+
* the saved refresh-token payload's `dpopJkt` is set so refresh
|
|
263
|
+
* rotation re-enforces sender constraint.
|
|
264
|
+
*/
|
|
265
|
+
dpopJkt?: string
|
|
266
|
+
/**
|
|
267
|
+
* OIDC Core §5.5 — RP-requested claims from `/authorize`. Carried
|
|
268
|
+
* into the id_token and forward across refresh rotations (§12) so
|
|
269
|
+
* later /userinfo calls keep returning the requested fields.
|
|
270
|
+
*/
|
|
271
|
+
claimsRequest?: import("../types/authorization").ClaimsRequest
|
|
272
|
+
}
|
|
223
273
|
family: string
|
|
224
274
|
/**
|
|
225
275
|
* `client_credentials` grants (RFC 6749 §4.4.3) and other paths where a
|
|
@@ -236,6 +286,11 @@ export async function mintTokens(args: {
|
|
|
236
286
|
issuerUrl: string
|
|
237
287
|
clock: () => number
|
|
238
288
|
newRefreshToken?: () => string
|
|
289
|
+
/**
|
|
290
|
+
* Host-supplied vendor scope → claim-names map merged into the
|
|
291
|
+
* id_token + /userinfo scope-gating. See `IdPOptions.customScopeClaims`.
|
|
292
|
+
*/
|
|
293
|
+
customScopeClaims?: Record<string, ReadonlyArray<string>>
|
|
239
294
|
}
|
|
240
295
|
}): Promise<Result<TokenResponse, AuthError>> {
|
|
241
296
|
const { tenant, claim, payload, deps, family, skipRefresh } = args
|
|
@@ -248,12 +303,22 @@ export async function mintTokens(args: {
|
|
|
248
303
|
? tenant.config.refreshTtl * 1000
|
|
249
304
|
: DEFAULT_REFRESH_TTL_MS
|
|
250
305
|
const now = deps.clock()
|
|
251
|
-
|
|
306
|
+
// OIDC Core §8.1 — `sectorIdentifier` from the receiving client drives
|
|
307
|
+
// pairwise vs public subject derivation. Look it up off the tenant
|
|
308
|
+
// config (already loaded by the grant flow). Absent = public.
|
|
309
|
+
const receivingClient = tenant.config.clients.find(
|
|
310
|
+
(c) => c.id === payload.clientId,
|
|
311
|
+
)
|
|
312
|
+
const subjectId = await deriveSubjectId(
|
|
313
|
+
claim,
|
|
314
|
+
receivingClient?.sectorIdentifier,
|
|
315
|
+
)
|
|
252
316
|
|
|
253
317
|
const keyRes = await deps.keyStore.currentSigningKey()
|
|
254
318
|
if (isErr(keyRes)) return err(keyRes.error)
|
|
255
319
|
const signingKey = keyRes.value
|
|
256
320
|
|
|
321
|
+
const userinfoClaimNames = Object.keys(payload.claimsRequest?.userinfo ?? {})
|
|
257
322
|
const claims: AccessTokenClaims = {
|
|
258
323
|
iss: deps.issuerUrl,
|
|
259
324
|
sub: subjectId,
|
|
@@ -265,6 +330,9 @@ export async function mintTokens(args: {
|
|
|
265
330
|
mkind: payload.methodKind,
|
|
266
331
|
scope: payload.scopes.join(" "),
|
|
267
332
|
claim,
|
|
333
|
+
...(payload.authTime !== undefined ? { auth_time: payload.authTime } : {}),
|
|
334
|
+
...(payload.dpopJkt !== undefined ? { cnf: { jkt: payload.dpopJkt } } : {}),
|
|
335
|
+
...(userinfoClaimNames.length > 0 ? { uic: userinfoClaimNames } : {}),
|
|
268
336
|
}
|
|
269
337
|
|
|
270
338
|
let accessToken: string
|
|
@@ -295,6 +363,14 @@ export async function mintTokens(args: {
|
|
|
295
363
|
family,
|
|
296
364
|
methodId: payload.methodId,
|
|
297
365
|
methodKind: payload.methodKind,
|
|
366
|
+
// Carry the original end-user auth time forward. Refresh-grant
|
|
367
|
+
// reissue uses this verbatim so `id_token.auth_time` is stable per
|
|
368
|
+
// OIDC Core §12 (refresh does not re-authenticate the user).
|
|
369
|
+
authTime: payload.authTime ?? Math.floor(now / 1000),
|
|
370
|
+
...(payload.dpopJkt !== undefined ? { dpopJkt: payload.dpopJkt } : {}),
|
|
371
|
+
...(payload.claimsRequest !== undefined
|
|
372
|
+
? { claimsRequest: payload.claimsRequest }
|
|
373
|
+
: {}),
|
|
298
374
|
issuedAt: now,
|
|
299
375
|
expiresAt: now + refreshTtl,
|
|
300
376
|
}
|
|
@@ -302,6 +378,41 @@ export async function mintTokens(args: {
|
|
|
302
378
|
if (isErr(saved)) return err(saved.error)
|
|
303
379
|
}
|
|
304
380
|
|
|
381
|
+
// OIDC id_token issuance — only when the grant carried an end-user
|
|
382
|
+
// (`authTime` present) AND `openid` scope was granted. Client-credentials
|
|
383
|
+
// never satisfies the first condition; refresh + code do.
|
|
384
|
+
let idToken: string | undefined
|
|
385
|
+
if (payload.authTime !== undefined && shouldIssueIdToken(payload.scopes)) {
|
|
386
|
+
const idClaims = await buildIdTokenClaims({
|
|
387
|
+
issuerUrl: deps.issuerUrl,
|
|
388
|
+
audience: payload.clientId,
|
|
389
|
+
subjectId,
|
|
390
|
+
claim,
|
|
391
|
+
scopes: payload.scopes,
|
|
392
|
+
authTime: payload.authTime,
|
|
393
|
+
...(payload.appNonce !== undefined ? { appNonce: payload.appNonce } : {}),
|
|
394
|
+
now,
|
|
395
|
+
methodKind: payload.methodKind,
|
|
396
|
+
accessToken,
|
|
397
|
+
...(payload.claimsRequest !== undefined
|
|
398
|
+
? { claimsRequest: payload.claimsRequest }
|
|
399
|
+
: {}),
|
|
400
|
+
...(deps.customScopeClaims !== undefined
|
|
401
|
+
? { customScopeClaims: deps.customScopeClaims }
|
|
402
|
+
: {}),
|
|
403
|
+
})
|
|
404
|
+
try {
|
|
405
|
+
idToken = await signIdToken(
|
|
406
|
+
idClaims,
|
|
407
|
+
signingKey.privateKeyRef as Parameters<typeof signIdToken>[1],
|
|
408
|
+
signingKey.alg,
|
|
409
|
+
signingKey.kid,
|
|
410
|
+
)
|
|
411
|
+
} catch (e) {
|
|
412
|
+
return err(authError.serverError("id_token sign failed", e))
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
305
416
|
await safeAudit(deps, {
|
|
306
417
|
kind: "token_issued",
|
|
307
418
|
tenantId: payload.tenantId,
|
|
@@ -310,14 +421,17 @@ export async function mintTokens(args: {
|
|
|
310
421
|
methodKind: payload.methodKind,
|
|
311
422
|
subjectId,
|
|
312
423
|
refreshTokenIdHash: refresh ? await hashTokenForAudit(refresh) : "",
|
|
424
|
+
...(idToken !== undefined ? { idTokenIssued: true } : {}),
|
|
425
|
+
...(payload.dpopJkt !== undefined ? { dpopBound: true } : {}),
|
|
313
426
|
timestamp: now,
|
|
314
427
|
})
|
|
315
428
|
|
|
316
429
|
return ok({
|
|
317
430
|
access_token: accessToken,
|
|
318
|
-
token_type: "Bearer",
|
|
431
|
+
token_type: payload.dpopJkt !== undefined ? "DPoP" : "Bearer",
|
|
319
432
|
expires_in: Math.floor(accessTtl / 1000),
|
|
320
433
|
...(refresh !== undefined ? { refresh_token: refresh } : {}),
|
|
434
|
+
...(idToken !== undefined ? { id_token: idToken } : {}),
|
|
321
435
|
scope: payload.scopes.join(" "),
|
|
322
436
|
})
|
|
323
437
|
}
|
|
@@ -336,11 +450,23 @@ export async function hashClientSecret(plain: string): Promise<string> {
|
|
|
336
450
|
/**
|
|
337
451
|
* Derive a stable subject id from the issued `SubjectClaim`. Hash inputs
|
|
338
452
|
* are canonicalized so reordered `properties` keys hash identically.
|
|
453
|
+
*
|
|
454
|
+
* OIDC Core §8.1 — when `sectorIdentifier` is supplied, the derivation
|
|
455
|
+
* mixes it in so the resulting `sub` is **pairwise**: identical across
|
|
456
|
+
* clients sharing that sector, distinct across sectors. Absent =
|
|
457
|
+
* **public** subject (same `sub` for every RP).
|
|
339
458
|
*/
|
|
340
|
-
async function deriveSubjectId(
|
|
459
|
+
async function deriveSubjectId(
|
|
460
|
+
claim: SubjectClaim,
|
|
461
|
+
sectorIdentifier?: string,
|
|
462
|
+
): Promise<string> {
|
|
341
463
|
const c = claim as { type: string; properties: Record<string, unknown> }
|
|
342
464
|
const ordered = canonicalize(c.properties)
|
|
343
|
-
|
|
465
|
+
const seed =
|
|
466
|
+
sectorIdentifier !== undefined
|
|
467
|
+
? `${sectorIdentifier}\0${c.type}\0${ordered}`
|
|
468
|
+
: `${c.type}\0${ordered}`
|
|
469
|
+
return base64url.encode(await sha256(seed)).slice(0, 22)
|
|
344
470
|
}
|
|
345
471
|
|
|
346
472
|
function canonicalize(value: unknown): string {
|
package/src/domain/userinfo.ts
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `/userinfo` endpoint logic (OIDC Core §5.3). Verifies the
|
|
3
|
-
* token
|
|
2
|
+
* `/userinfo` endpoint logic (OIDC Core §5.3). Verifies the presented
|
|
3
|
+
* access token, optionally enforces a DPoP proof binding (RFC 9449 §7),
|
|
4
|
+
* and returns the inlined `SubjectClaim` claims, scope-gated per OIDC
|
|
5
|
+
* Core §5.4.
|
|
6
|
+
*
|
|
7
|
+
* Profile / email / phone / address claims appear in the response only
|
|
8
|
+
* when (a) the access token's `scope` includes the granting scope and
|
|
9
|
+
* (b) the value exists on `SubjectClaim.properties`. Other properties
|
|
10
|
+
* (host-specific subject fields like `userId`, `roles`, …) are returned
|
|
11
|
+
* under `properties` and not subject to OIDC scope gating — they're
|
|
12
|
+
* outside §5.4's universe.
|
|
13
|
+
*
|
|
14
|
+
* DPoP at the resource server (RFC 9449 §7):
|
|
15
|
+
* - If the access token has a `cnf.jkt` claim, the request MUST carry a
|
|
16
|
+
* matching `DPoP:` proof whose `ath` equals SHA-256(access_token).
|
|
17
|
+
* - If no `cnf.jkt`, the access token is plain Bearer and any DPoP
|
|
18
|
+
* proof is silently ignored at this layer (host can still enforce).
|
|
4
19
|
*/
|
|
20
|
+
import type { TokenStore } from "../ports/token-store"
|
|
5
21
|
import type { KeyStore } from "../ports/key-store"
|
|
6
22
|
import { authError, type AuthError } from "../types/error"
|
|
7
23
|
import type { Result } from "../types/result"
|
|
@@ -9,13 +25,16 @@ import { err, isErr, ok } from "../types/result"
|
|
|
9
25
|
import type { SubjectClaim } from "../types/subject"
|
|
10
26
|
import type { AccessTokenClaims } from "../types/token"
|
|
11
27
|
|
|
28
|
+
import { computeAth, verifyDpopProof } from "./dpop"
|
|
29
|
+
import { pickScopedClaims } from "./id-token"
|
|
12
30
|
import { verifyAccessToken } from "./jwt"
|
|
31
|
+
import type { ScopedProfileClaims } from "../types/token"
|
|
13
32
|
|
|
14
|
-
export type UserinfoResponse = {
|
|
33
|
+
export type UserinfoResponse = ScopedProfileClaims & {
|
|
15
34
|
sub: string
|
|
16
35
|
/** Subject type discriminator (e.g. `"user"` / `"admin"`). */
|
|
17
36
|
subject_type: string
|
|
18
|
-
/** Inlined claim properties. */
|
|
37
|
+
/** Inlined claim properties, host-specific and not gated by OIDC scope. */
|
|
19
38
|
properties: Record<string, unknown>
|
|
20
39
|
/** Scope granted to this access token. */
|
|
21
40
|
scope?: string
|
|
@@ -23,11 +42,34 @@ export type UserinfoResponse = {
|
|
|
23
42
|
|
|
24
43
|
export type UserinfoDeps = {
|
|
25
44
|
keyStore: KeyStore
|
|
45
|
+
/** Required when DPoP-bound tokens are accepted. */
|
|
46
|
+
tokenStore?: TokenStore
|
|
26
47
|
issuerUrl?: string
|
|
48
|
+
/**
|
|
49
|
+
* Host-supplied vendor scope → claim-names map. Merged into the
|
|
50
|
+
* scope-gated /userinfo response just as it is for id_token. See
|
|
51
|
+
* `IdPOptions.customScopeClaims`.
|
|
52
|
+
*/
|
|
53
|
+
customScopeClaims?: Record<string, ReadonlyArray<string>>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type UserinfoInput = {
|
|
57
|
+
/** Access token from `Authorization: Bearer ...` or `Authorization: DPoP ...`. */
|
|
58
|
+
accessToken: string
|
|
59
|
+
/** `Authorization`-scheme as presented. Defaults to `"Bearer"`. */
|
|
60
|
+
scheme?: "Bearer" | "DPoP"
|
|
61
|
+
/** Raw `DPoP:` header from the request, if any. */
|
|
62
|
+
dpopProof?: string
|
|
63
|
+
/** Canonical request URI for DPoP `htu` check (scheme + host + path). */
|
|
64
|
+
htu?: string
|
|
65
|
+
/** Request method for DPoP `htm` check, uppercase. */
|
|
66
|
+
htm?: string
|
|
67
|
+
/** Wall clock seconds for DPoP `iat` window. */
|
|
68
|
+
nowSec?: number
|
|
27
69
|
}
|
|
28
70
|
|
|
29
71
|
export async function userinfo(
|
|
30
|
-
|
|
72
|
+
input: UserinfoInput,
|
|
31
73
|
deps: UserinfoDeps,
|
|
32
74
|
): Promise<Result<UserinfoResponse, AuthError>> {
|
|
33
75
|
const keysRes = await deps.keyStore.signingKeys()
|
|
@@ -35,22 +77,95 @@ export async function userinfo(
|
|
|
35
77
|
|
|
36
78
|
let claims: AccessTokenClaims
|
|
37
79
|
try {
|
|
38
|
-
claims = await verifyAccessToken(
|
|
80
|
+
claims = await verifyAccessToken(input.accessToken, keysRes.value, {
|
|
39
81
|
...(deps.issuerUrl ? { issuer: deps.issuerUrl } : {}),
|
|
40
82
|
})
|
|
41
83
|
} catch {
|
|
42
84
|
return err(authError.invalidGrant("access token invalid or expired"))
|
|
43
85
|
}
|
|
44
86
|
|
|
87
|
+
// RFC 9449 §7 — DPoP-bound tokens require a matching proof at the RS.
|
|
88
|
+
const boundJkt = claims.cnf?.jkt
|
|
89
|
+
if (boundJkt !== undefined) {
|
|
90
|
+
if (input.scheme !== "DPoP") {
|
|
91
|
+
return err(
|
|
92
|
+
authError.invalidDpopProof(
|
|
93
|
+
'access token is DPoP-bound; Authorization scheme must be "DPoP"',
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
if (!input.dpopProof) {
|
|
98
|
+
return err(
|
|
99
|
+
authError.invalidDpopProof(
|
|
100
|
+
"access token is DPoP-bound; request is missing a DPoP proof",
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (!input.htu || !input.htm || input.nowSec === undefined) {
|
|
105
|
+
return err(
|
|
106
|
+
authError.invalidDpopProof(
|
|
107
|
+
"userinfo dpop verification requires htu, htm, and nowSec",
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
if (!deps.tokenStore) {
|
|
112
|
+
return err(
|
|
113
|
+
authError.invalidDpopProof(
|
|
114
|
+
"userinfo dpop verification requires a token-store (jti replay protection)",
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
const expectedAth = await computeAth(input.accessToken)
|
|
119
|
+
const dpopRes = await verifyDpopProof(
|
|
120
|
+
{
|
|
121
|
+
proofJwt: input.dpopProof,
|
|
122
|
+
htu: input.htu,
|
|
123
|
+
htm: input.htm,
|
|
124
|
+
nowSec: input.nowSec,
|
|
125
|
+
expectedAth,
|
|
126
|
+
},
|
|
127
|
+
{ tokenStore: deps.tokenStore },
|
|
128
|
+
)
|
|
129
|
+
if (isErr(dpopRes)) return err(dpopRes.error)
|
|
130
|
+
if (dpopRes.value.jkt !== boundJkt) {
|
|
131
|
+
return err(
|
|
132
|
+
authError.invalidDpopProof(
|
|
133
|
+
"dpop proof key does not match access token cnf.jkt",
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
} else if (input.scheme === "DPoP") {
|
|
138
|
+
// Token isn't bound but caller used the DPoP scheme — reject so a
|
|
139
|
+
// confused-deputy can't accidentally rely on RS-side enforcement.
|
|
140
|
+
return err(
|
|
141
|
+
authError.invalidGrant(
|
|
142
|
+
"access token is not DPoP-bound; use Authorization: Bearer",
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
45
147
|
const claim = claims.claim as SubjectClaim & {
|
|
46
148
|
type: string
|
|
47
149
|
properties: Record<string, unknown>
|
|
48
150
|
}
|
|
49
151
|
|
|
152
|
+
const scopes = claims.scope ? claims.scope.split(" ").filter(Boolean) : []
|
|
153
|
+
// OIDC Core §5.5 — claims the RP requested via `claims=...userinfo:...`
|
|
154
|
+
// bypass scope gating. Names were captured into `uic` at mint time so
|
|
155
|
+
// the resource server doesn't need to re-resolve the original /authorize
|
|
156
|
+
// request.
|
|
157
|
+
const scopedClaims = pickScopedClaims(
|
|
158
|
+
claim,
|
|
159
|
+
scopes,
|
|
160
|
+
claims.uic ?? [],
|
|
161
|
+
deps.customScopeClaims ?? {},
|
|
162
|
+
)
|
|
163
|
+
|
|
50
164
|
return ok({
|
|
51
165
|
sub: claims.sub,
|
|
52
166
|
subject_type: claim.type,
|
|
53
167
|
properties: claim.properties,
|
|
54
168
|
scope: claims.scope,
|
|
169
|
+
...scopedClaims,
|
|
55
170
|
})
|
|
56
171
|
}
|
package/src/http/context.ts
CHANGED
|
@@ -17,7 +17,10 @@ import type { MethodCache } from "../domain/method-cache"
|
|
|
17
17
|
import type { AuthError } from "../types/error"
|
|
18
18
|
import type {
|
|
19
19
|
ExchangeAudience,
|
|
20
|
+
LogoutEventInput,
|
|
21
|
+
LogoutHookResult,
|
|
20
22
|
PersistUpstreamTokens,
|
|
23
|
+
RegisterClient,
|
|
21
24
|
RenderPicker,
|
|
22
25
|
SuccessMapInput,
|
|
23
26
|
} from "../types/idp"
|
|
@@ -48,15 +51,22 @@ export type HttpDeps = {
|
|
|
48
51
|
callbackHostFor?: (tenantId: TenantId) => string
|
|
49
52
|
resolveTenant: (req: Request) => Promise<Result<TenantId, AuthError>>
|
|
50
53
|
success: (input: SuccessMapInput) => Promise<SubjectClaim>
|
|
54
|
+
/** See `IdPOptions.onLogout` — upstream Single Logout host hook. */
|
|
55
|
+
onLogout?: (
|
|
56
|
+
input: LogoutEventInput,
|
|
57
|
+
) => Promise<LogoutHookResult> | LogoutHookResult
|
|
51
58
|
persistUpstreamTokens?: PersistUpstreamTokens
|
|
52
59
|
exchangeAudience?: ExchangeAudience
|
|
53
60
|
renderPicker?: RenderPicker
|
|
61
|
+
registerClient?: RegisterClient
|
|
54
62
|
/** Builds `TenantContext.request.custom` for every request. See `IdPOptions.buildCustomContext`. */
|
|
55
63
|
buildCustomContext?: (
|
|
56
64
|
req: Request,
|
|
57
65
|
) => Record<string, unknown> | Promise<Record<string, unknown>>
|
|
58
66
|
clock: () => number
|
|
59
67
|
cookieDefaults: CookieDefaults
|
|
68
|
+
/** See `IdPOptions.customScopeClaims`. */
|
|
69
|
+
customScopeClaims?: Record<string, ReadonlyArray<string>>
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** Per-request variables populated by middleware. */
|
package/src/http/cookies.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* strip method-returned `Set-Cookie` / security / `Cache-Control` headers so
|
|
8
8
|
* methods cannot bypass policy.
|
|
9
9
|
*/
|
|
10
|
-
import type { SetCookie } from "../types/method"
|
|
10
|
+
import type { CachePolicy, SetCookie } from "../types/method"
|
|
11
11
|
|
|
12
12
|
/** Parse an incoming `Cookie:` header into a read-only map. */
|
|
13
13
|
export function parseCookieHeader(header: string | null): Map<string, string> {
|
|
@@ -117,6 +117,24 @@ const STRIPPED_HEADERS = [
|
|
|
117
117
|
"cache-control",
|
|
118
118
|
] as const
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Serialize a `MethodResult.challenge`'s `CachePolicy` into a
|
|
122
|
+
* `Cache-Control` header value. The framework owns this header (methods
|
|
123
|
+
* cannot set it directly — see `STRIPPED_HEADERS`); a method opts into
|
|
124
|
+
* caching via the typed `cache` field and this renders it. Absent /
|
|
125
|
+
* `maxAge: 0` ⇒ `no-store` (the safe default for auth UI).
|
|
126
|
+
*/
|
|
127
|
+
export function cacheControlHeader(cache: CachePolicy | undefined): string {
|
|
128
|
+
if (!cache) return "no-store"
|
|
129
|
+
if ((cache.maxAge ?? -1) === 0) return "no-store"
|
|
130
|
+
const parts: string[] = []
|
|
131
|
+
if (cache.isPrivate) parts.push("private")
|
|
132
|
+
if (cache.maxAge !== undefined) parts.push(`max-age=${cache.maxAge}`)
|
|
133
|
+
if (cache.sMaxAge !== undefined) parts.push(`s-maxage=${cache.sMaxAge}`)
|
|
134
|
+
if (cache.immutable) parts.push("immutable")
|
|
135
|
+
return parts.length ? parts.join(", ") : "no-store"
|
|
136
|
+
}
|
|
137
|
+
|
|
120
138
|
export type ApplyOptions = {
|
|
121
139
|
setCookies?: SetCookie[]
|
|
122
140
|
cookieDefaults?: CookieDefaults
|