@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.
- package/package.json +3 -3
- 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.
|
|
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.
|
|
28
|
-
"@fabrika/auth-core": "0.
|
|
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
|
-
|
|
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
|
-
||
|
|
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
|
|
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))
|
|
141
|
-
|
|
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 >
|
|
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 (
|
|
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')
|