@fabrika/iam-contract 0.8.1 → 0.9.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/oauth.ts +71 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/iam-contract",
3
- "version": "0.8.1",
3
+ "version": "0.9.0-dev.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,7 +24,7 @@
24
24
  "typecheck": "tsc --noEmit"
25
25
  },
26
26
  "dependencies": {
27
- "@fabrika/app": "0.8.1",
28
- "@fabrika/auth-core": "0.8.1"
27
+ "@fabrika/app": "0.9.0-dev.1",
28
+ "@fabrika/auth-core": "0.9.0-dev.1"
29
29
  }
30
30
  }
package/src/oauth.ts CHANGED
@@ -8,15 +8,34 @@ export interface OAuthManagedClient {
8
8
  redirectUris: string[]
9
9
  }
10
10
 
11
- export interface OAuthClientMetadataDocument {
11
+ /** RFC 7523 §2.2: the `client_assertion_type` of a JWT client assertion (`private_key_jwt`). */
12
+ export const OAUTH_CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
13
+
14
+ /** The signing algorithms IAM verifies a client assertion with. */
15
+ export const OAUTH_CLIENT_ASSERTION_ALGORITHMS = ['RS256'] as const
16
+ export type OAuthClientAssertionAlgorithm = (typeof OAUTH_CLIENT_ASSERTION_ALGORITHMS)[number]
17
+
18
+ interface OAuthClientMetadataFields {
12
19
  client_id: string
13
20
  client_name?: string
14
21
  redirect_uris: string[]
15
- token_endpoint_auth_method: 'none'
16
22
  grant_types: ('authorization_code' | 'refresh_token')[]
17
23
  response_types: ['code']
18
24
  }
19
25
 
26
+ /** A public client, or one that authenticates with a JWT signed by a key its document publishes. */
27
+ export type OAuthClientMetadataDocument =
28
+ | (OAuthClientMetadataFields & { token_endpoint_auth_method: 'none' })
29
+ | (OAuthClientMetadataFields & {
30
+ token_endpoint_auth_method: 'private_key_jwt'
31
+ /** HTTPS, on the client id's own origin. */
32
+ jwks_uri: string
33
+ token_endpoint_auth_signing_alg: OAuthClientAssertionAlgorithm
34
+ })
35
+
36
+ /** How a token or revocation request authenticates its client: no credential, or a JWT assertion. */
37
+ export type OAuthClientCredential = { method: 'none' } | { method: 'private_key_jwt'; assertion: string }
38
+
20
39
  export interface OAuthResourceDto {
21
40
  app: string
22
41
  resource: string
@@ -84,7 +103,12 @@ export interface OAuthAuthorizationServerMetadata {
84
103
  response_types_supported: ['code']
85
104
  grant_types_supported: ['authorization_code', 'refresh_token']
86
105
  code_challenge_methods_supported: ['S256']
87
- token_endpoint_auth_methods_supported: ['none']
106
+ token_endpoint_auth_methods_supported: ['none', 'private_key_jwt']
107
+ token_endpoint_auth_signing_alg_values_supported: OAuthClientAssertionAlgorithm[]
108
+ revocation_endpoint_auth_methods_supported: ['none', 'private_key_jwt']
109
+ revocation_endpoint_auth_signing_alg_values_supported: OAuthClientAssertionAlgorithm[]
110
+ /** RFC 9207: every authorization response carries `iss`. */
111
+ authorization_response_iss_parameter_supported: true
88
112
  client_id_metadata_document_supported: true
89
113
  }
90
114
 
@@ -126,27 +150,50 @@ export function parseOAuthClientMetadata(value: unknown, registration: OAuthMana
126
150
  const responses = rawResponses === undefined ? ['code'] : rawResponses
127
151
  if (
128
152
  managed === null || clientId !== managed.clientId || uris === null || !uris.every((uri) => managed.redirectUris.includes(uri))
129
- || field(value, 'token_endpoint_auth_method') !== 'none' || (clientName !== undefined && !isNonemptyText(clientName))
153
+ || (clientName !== undefined && !isNonemptyText(clientName))
130
154
  || field(value, 'client_secret') !== undefined || field(value, 'client_secret_expires_at') !== undefined || grants === null
131
155
  || !Array.isArray(responses) || responses.length !== 1 || responses[0] !== 'code'
132
156
  ) return null
133
- const metadata: OAuthClientMetadataDocument = {
157
+ const fields: OAuthClientMetadataFields = {
134
158
  client_id: managed.clientId,
135
159
  redirect_uris: uris,
136
- token_endpoint_auth_method: 'none',
137
160
  grant_types: grants,
138
161
  response_types: ['code'],
139
162
  }
140
- if (isNonemptyText(clientName)) metadata.client_name = clientName
141
- return metadata
163
+ if (isNonemptyText(clientName)) fields.client_name = clientName
164
+ const method = field(value, 'token_endpoint_auth_method')
165
+ if (method === 'none') return { ...fields, token_endpoint_auth_method: 'none' }
166
+ if (method !== 'private_key_jwt') return null
167
+ const jwksUri = field(value, 'jwks_uri')
168
+ const algorithm = field(value, 'token_endpoint_auth_signing_alg')
169
+ // An inline `jwks` would bypass the origin rule below; RFC 7591 §2 forbids it beside `jwks_uri` anyway.
170
+ if (field(value, 'jwks') !== undefined || !isClientOriginUrl(jwksUri, managed.clientId) || !isClientAssertionAlgorithm(algorithm)) return null
171
+ return { ...fields, token_endpoint_auth_method: 'private_key_jwt', jwks_uri: jwksUri, token_endpoint_auth_signing_alg: algorithm }
172
+ }
173
+
174
+ function isClientAssertionAlgorithm(value: unknown): value is OAuthClientAssertionAlgorithm {
175
+ return OAUTH_CLIENT_ASSERTION_ALGORITHMS.some((algorithm) => algorithm === value)
176
+ }
177
+
178
+ /** An HTTPS URL on the client id's own origin, so the client's own host vouches for its keys. */
179
+ function isClientOriginUrl(value: unknown, clientId: string): value is string {
180
+ if (typeof value !== 'string' || value.includes('#')) return false
181
+ try {
182
+ const url = new URL(value)
183
+ return url.protocol === 'https:' && url.username === '' && url.password === '' && url.origin === new URL(clientId).origin
184
+ } catch {
185
+ return false
186
+ }
142
187
  }
143
188
 
144
189
  function parseClientGrantTypes(value: unknown): OAuthClientMetadataDocument['grant_types'] | null {
145
190
  if (value === undefined) return ['authorization_code']
146
- if (!Array.isArray(value) || value.length === 0 || value.length > 2) return null
191
+ if (!Array.isArray(value) || value.length === 0 || value.length > 16) return null
147
192
  const grants: OAuthClientMetadataDocument['grant_types'] = []
148
193
  for (const grant of value) {
149
- if ((grant !== 'authorization_code' && grant !== 'refresh_token') || grants.includes(grant)) return null
194
+ if (typeof grant !== 'string') return null
195
+ // A client may list grants IAM does not serve (Claude lists jwt-bearer); IAM serves the intersection.
196
+ if ((grant !== 'authorization_code' && grant !== 'refresh_token') || grants.includes(grant)) continue
150
197
  grants.push(grant)
151
198
  }
152
199
  return grants.includes('authorization_code') ? grants : null
@@ -261,6 +308,20 @@ export function parseOAuthTokenRequest(value: unknown): OAuthTokenRequest | null
261
308
  return null
262
309
  }
263
310
 
311
+ const MAX_CLIENT_ASSERTION_LENGTH = 16_384
312
+
313
+ /**
314
+ * The client credential of a token or revocation request (RFC 7521 §4.2). Neither assertion field is no
315
+ * credential; a half-present pair, another assertion type or an oversized assertion is null.
316
+ */
317
+ export function parseOAuthClientCredential(value: unknown): OAuthClientCredential | null {
318
+ const type = field(value, 'client_assertion_type')
319
+ const assertion = field(value, 'client_assertion')
320
+ if (type === undefined && assertion === undefined) return { method: 'none' }
321
+ if (type !== OAUTH_CLIENT_ASSERTION_TYPE || !isNonemptyText(assertion) || assertion.length > MAX_CLIENT_ASSERTION_LENGTH) return null
322
+ return { method: 'private_key_jwt', assertion }
323
+ }
324
+
264
325
  export function parseOAuthRevocationRequest(value: unknown): OAuthRevocationRequest | null {
265
326
  const clientId = field(value, 'client_id')
266
327
  const token = field(value, 'token')