@fabrika/iam-contract 0.3.2 → 0.4.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 (3) hide show
  1. package/package.json +3 -3
  2. package/src/index.ts +186 -5
  3. package/src/oauth.ts +275 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/iam-contract",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
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.3.2",
28
- "@fabrika/auth-core": "^0.3.2"
27
+ "@fabrika/app": "^0.4.0",
28
+ "@fabrika/auth-core": "^0.4.0"
29
29
  }
30
30
  }
package/src/index.ts CHANGED
@@ -9,7 +9,20 @@
9
9
  // where they fit. Keep these clean and complete: they are the admin API contract.
10
10
 
11
11
  import type { RpcProcedureContract } from '@fabrika/app'
12
- import type { AppActionDef, AppSchema, AppScopeDef, PermissionEntry, PermissionSource, PrincipalType, RoleDef } from '@fabrika/auth-core'
12
+ import type {
13
+ AppActionDef,
14
+ AppDelegationDef,
15
+ AppLinkMountDef,
16
+ AppMemberItem,
17
+ AppSchema,
18
+ AppScopeDef,
19
+ Locale,
20
+ PermissionEntry,
21
+ PermissionSource,
22
+ PrincipalAffiliation,
23
+ PrincipalType,
24
+ RoleDef,
25
+ } from '@fabrika/auth-core'
13
26
 
14
27
  // ── Common wrappers ───────────────────────────────────────────────────────────
15
28
 
@@ -39,6 +52,8 @@ export interface PrincipalListItem {
39
52
  email: string | null
40
53
  externalId: string | null
41
54
  status: PrincipalStatus
55
+ /** `external`: a person from outside the organization, who holds only roles declared external (ADR-0051). */
56
+ affiliation: PrincipalAffiliation
42
57
  createdAt: number
43
58
  }
44
59
 
@@ -63,6 +78,8 @@ export interface GrantDto {
63
78
  }
64
79
 
65
80
  export interface PrincipalDetail extends PrincipalListItem {
81
+ /** The mailbox is listed in `IAM_BOOTSTRAP_ADMINS`: an internal user with it is an installation admin. */
82
+ bootstrapMailbox: boolean
66
83
  grants: GrantDto[]
67
84
  /** Effective permissions with `source` (grant / bootstrap / group:<ref>). */
68
85
  permissions: PermissionEntry[]
@@ -76,6 +93,8 @@ export interface PrincipalDetail extends PrincipalListItem {
76
93
 
77
94
  export interface InviteRequest {
78
95
  email: string
96
+ /** Omitted: `internal`. An `external` user signs in by password only, and OIDC never claims it. */
97
+ affiliation?: PrincipalAffiliation
79
98
  }
80
99
 
81
100
  export interface UpdatePrincipalRequest {
@@ -156,6 +175,18 @@ export interface AppSchemaDto {
156
175
  actions: AppActionDef[]
157
176
  /** role_key -> def, origin='app' only (reconciled from code). */
158
177
  roles: Record<string, RoleDef>
178
+ /** Share-link mounts; absent when the app declares none. */
179
+ links?: Record<string, AppLinkMountDef>
180
+ /** Endpoints that may spend membership authority; absent when the app declares none. */
181
+ delegations?: Record<string, AppDelegationDef>
182
+ /** The language IAM speaks to the app's people; absent when the app declares none. */
183
+ locale?: Locale
184
+ /** The name IAM shows for the app; absent when the app declares none. */
185
+ displayName?: string
186
+ /** The actions that manage and list the app's own people; absent when the app declares none. */
187
+ members?: { manage: string; list: string }
188
+ /** `labels` when the app's directory shows labels to `members.list` holders. */
189
+ directory?: 'labels'
159
190
  }
160
191
 
161
192
  // ── Policies (origin='custom' roles, admin-composed) ──────────────────────────
@@ -253,7 +284,8 @@ export interface IssueShareLinkRequest {
253
284
  /** Each grant is delegation-checked against the issuer on its `scope` (omitted → global). */
254
285
  grants: { action: string; scope?: { type: string; value: string } | null }[]
255
286
  label?: string
256
- expiresAt?: number
287
+ /** Absolute expiry (unix seconds). REQUIRED, and at most `MAX_KEY_LIFETIME_SECONDS` ahead: no key is permanent. */
288
+ expiresAt: number
257
289
  }
258
290
 
259
291
  /** Issued share-link result — the plaintext `px_` token returned ONCE. */
@@ -283,11 +315,14 @@ export interface AuthLogDto {
283
315
  id: number
284
316
  requestId: string
285
317
  app: string
286
- kind: 'authenticate'
318
+ /** A login attempt or a mint (`authenticate`), or the end of a session (`logout`). */
319
+ kind: 'authenticate' | 'logout'
287
320
  principalId: string | null
288
321
  credentialId: string | null
289
322
  decision: 'allow' | 'deny'
290
323
  reason: string | null
324
+ /** The login opened a session of an installation administrator. */
325
+ privileged: boolean
291
326
  createdAt: number
292
327
  }
293
328
 
@@ -335,8 +370,9 @@ export interface RotateApiKeyInput extends ApiKeyIdInput {
335
370
  expiresAt?: number | null
336
371
  }
337
372
 
373
+ /** Every procedure that names a principal keys it as `principalId`; a bare `id` names some other resource. */
338
374
  export interface PrincipalIdInput {
339
- id: string
375
+ principalId: string
340
376
  }
341
377
 
342
378
  export interface ListSessionsInput extends PageInput {
@@ -432,6 +468,127 @@ export interface ListAuthLogInput {
432
468
  type RpcProcedure<TInput, TOutput> = RpcProcedureContract<TInput, TOutput>
433
469
 
434
470
  /** Browser-safe typed contract for the IAM administration API. */
471
+ // ── Workload keys (Control's reconcile of jobs and webhooks) ──────────────────
472
+
473
+ /**
474
+ * Issue a fresh key for one workload of an application (ADR-0022 "Principal kinds"). The key resolves to the actions
475
+ * the app's reconciled schema declares for the workload. Every other key of the workload is revoked except `keep`.
476
+ */
477
+ export interface IssueWorkloadKeyRequest {
478
+ /** The IAM application id `<app>@<env>`. */
479
+ app: string
480
+ /** `app` is the connector of the application named `name`, which consumes an export of `app`. */
481
+ kind: 'job' | 'webhook' | 'app'
482
+ name: string
483
+ /** Absolute expiry (unix seconds), after now and at most 30 days ahead. */
484
+ expiresAt: number
485
+ /**
486
+ * The credential id of the key the caller has recorded as active, which stays valid until the caller replaces it; null
487
+ * when it has none. A key issued earlier and never recorded is an orphan, and is revoked.
488
+ */
489
+ keep: string | null
490
+ }
491
+
492
+ /** The plaintext key is returned once. */
493
+ export interface IssueWorkloadKeyResponse {
494
+ key: string
495
+ credentialId: string
496
+ principalId: string
497
+ expiresAt: number
498
+ }
499
+
500
+ /**
501
+ * Revoke every key of one workload of `app`, the key a rotation kept included: what Control asks when a consumer stops
502
+ * importing from `app`. A workload with no principal has nothing to revoke.
503
+ */
504
+ export interface RevokeWorkloadKeysRequest {
505
+ app: string
506
+ kind: 'job' | 'webhook' | 'app'
507
+ name: string
508
+ }
509
+
510
+ export interface RevokeWorkloadKeysResponse {
511
+ revoked: number
512
+ }
513
+
514
+ // ── Membership transport keys (Control's reconcile of applications without an IAM binding) ──
515
+
516
+ /**
517
+ * Issue a fresh membership transport key for one application environment (ADR-0022 rule 6). The key authenticates
518
+ * IAM's `/membership/*` surface and pins the application id; it carries no authority. Every other key of the
519
+ * application environment is deleted except `keep`.
520
+ */
521
+ export interface IssueMembershipKeyRequest {
522
+ /** The IAM application id `<app>@<env>`. */
523
+ app: string
524
+ /** Absolute expiry (unix seconds), after now and at most 30 days ahead. */
525
+ expiresAt: number
526
+ /** The id of the key the caller has recorded as active, which stays valid until the caller replaces it; null when it has none. */
527
+ keep: string | null
528
+ }
529
+
530
+ /** The issued key, shown once. `id` is what a later issuance keeps. */
531
+ export interface IssueMembershipKeyResponse {
532
+ key: string
533
+ id: string
534
+ expiresAt: number
535
+ }
536
+
537
+ // ── An application's own people, administered by its own administrator ───────
538
+
539
+ /**
540
+ * An application whose people the caller administers: it holds the application's `members.manage` globally there.
541
+ * `roles` are the application's global roles the caller may grant, those whose every action it holds itself.
542
+ */
543
+ export interface AdministeredAppDto {
544
+ app: string
545
+ displayName: string | null
546
+ roles: { key: string; name: string; permissions: string[] }[]
547
+ }
548
+
549
+ export interface AppPeopleInput {
550
+ app: string
551
+ /** The previous page's `next`. */
552
+ after?: string
553
+ }
554
+
555
+ /** One page of the application's people, as its administrator sees them: labels, addresses and last sign-ins. */
556
+ export interface AppPeopleDto {
557
+ app: string
558
+ members: AppMemberItem[]
559
+ next: string | null
560
+ }
561
+
562
+ /**
563
+ * Give a person who already has an IAM principal one of the application's global roles, by its address (the people
564
+ * list answers each person's). An address IAM knows no person for is a 404: an IAM administrator invites people, an
565
+ * application's administrator does not. Holding the role already answers the same.
566
+ */
567
+ export interface GrantAppRoleRequest {
568
+ app: string
569
+ role: string
570
+ email: string
571
+ }
572
+
573
+ export interface GrantAppRoleResponse {
574
+ principalId: string
575
+ role: string
576
+ /** False when the person held the role already. */
577
+ granted: boolean
578
+ }
579
+
580
+ /** Remove the global grant of `role` from a person. Repeating it answers the same. */
581
+ export interface RevokeAppRoleRequest {
582
+ app: string
583
+ role: string
584
+ principalId: string
585
+ }
586
+
587
+ export interface RevokeAppRoleResponse {
588
+ /** False when the person held no such grant. */
589
+ revoked: boolean
590
+ }
591
+
435
592
  export interface IamAdminRpcContract {
436
593
  me: RpcProcedure<void, MeDto>
437
594
  principals: {
@@ -439,6 +596,11 @@ export interface IamAdminRpcContract {
439
596
  get: RpcProcedure<PrincipalIdInput, PrincipalDetail>
440
597
  invite: RpcProcedure<InviteRequest, PrincipalListItem>
441
598
  update: RpcProcedure<UpdatePrincipalInput, PrincipalListItem>
599
+ /**
600
+ * Make an external user internal, revoking its sessions and pending password actions. A bootstrap mailbox becomes
601
+ * an installation admin. There is no way back; promoting an internal principal is a 409.
602
+ */
603
+ promote: RpcProcedure<PrincipalIdInput, PrincipalListItem>
442
604
  }
443
605
  /**
444
606
  * Sessions an operator no longer trusts. Revoking an IAM session also ends every app session
@@ -455,6 +617,7 @@ export interface IamAdminRpcContract {
455
617
  disable: RpcProcedure<PrincipalIdInput, OkResponse>
456
618
  }
457
619
  grants: {
620
+ /** A role grant the principal already holds — same role, scope and app — answers 409 naming it. */
458
621
  create: RpcProcedure<CreateGrantRequest, GrantDto>
459
622
  delete: RpcProcedure<GrantIdInput, OkResponse>
460
623
  }
@@ -478,6 +641,13 @@ export interface IamAdminRpcContract {
478
641
  rotate: RpcProcedure<RotateApiKeyInput, RotateApiKeyResponse>
479
642
  revoke: RpcProcedure<ApiKeyIdInput, OkResponse>
480
643
  }
644
+ workloads: {
645
+ issueKey: RpcProcedure<IssueWorkloadKeyRequest, IssueWorkloadKeyResponse>
646
+ revokeKeys: RpcProcedure<RevokeWorkloadKeysRequest, RevokeWorkloadKeysResponse>
647
+ }
648
+ membershipKeys: {
649
+ issue: RpcProcedure<IssueMembershipKeyRequest, IssueMembershipKeyResponse>
650
+ }
481
651
  shareLinks: {
482
652
  list: RpcProcedure<ListShareLinksInput, CursorList<ShareLinkListItem>>
483
653
  issue: RpcProcedure<IssueShareLinkRequest, IssuedShareLinkResponse>
@@ -487,6 +657,17 @@ export interface IamAdminRpcContract {
487
657
  list: RpcProcedure<ListAuditInput, CursorList<AuditEventDto>>
488
658
  listAuthLog: RpcProcedure<ListAuthLogInput, CursorList<AuthLogDto>>
489
659
  }
660
+ /**
661
+ * An application's own people, administered by whoever holds the application's `members.manage` globally there: no
662
+ * installation admin needed. Each change writes its audit row under the application.
663
+ */
664
+ appPeople: {
665
+ apps: RpcProcedure<void, ListResponse<AdministeredAppDto>>
666
+ list: RpcProcedure<AppPeopleInput, AppPeopleDto>
667
+ grant: RpcProcedure<GrantAppRoleRequest, GrantAppRoleResponse>
668
+ revoke: RpcProcedure<RevokeAppRoleRequest, RevokeAppRoleResponse>
669
+ }
490
670
  }
491
671
 
492
- export type { AppActionDef, AppSchema, AppScopeDef, PermissionEntry, PermissionSource, PrincipalType, RoleDef }
672
+ export type { AppActionDef, AppMemberItem, AppSchema, AppScopeDef, PermissionEntry, PermissionSource, PrincipalAffiliation, PrincipalType, RoleDef }
673
+ export * from './oauth'
package/src/oauth.ts ADDED
@@ -0,0 +1,275 @@
1
+ import { isCimdClientId, isOAuthResourceUri, parseOAuthScopes } from '@fabrika/auth-core'
2
+ import type { KeyGrant } from '@fabrika/auth-core'
3
+
4
+ /** Managed CIMD registrations only; public dynamic registration is not part of this contract. */
5
+ export interface OAuthManagedClient {
6
+ provider: 'chatgpt' | 'claude'
7
+ clientId: string
8
+ redirectUris: string[]
9
+ }
10
+
11
+ export interface OAuthClientMetadataDocument {
12
+ client_id: string
13
+ client_name?: string
14
+ redirect_uris: string[]
15
+ token_endpoint_auth_method: 'none'
16
+ grant_types: ('authorization_code' | 'refresh_token')[]
17
+ response_types: ['code']
18
+ }
19
+
20
+ export interface OAuthResourceDto {
21
+ app: string
22
+ resource: string
23
+ /** IAM resolves these app-owned grants; OAuth scope text alone never authorizes an action. */
24
+ scopeGrants: { scope: string; permissions: KeyGrant[] }[]
25
+ /** Used only when scope is omitted. No configured default means invalid_scope, never all scopes. */
26
+ defaultScopes?: string[]
27
+ }
28
+
29
+ /** No access token, refresh token, hash, or authorization code appears in a grant DTO. */
30
+ export interface OAuthGrantDto {
31
+ id: string
32
+ principalId: string
33
+ clientId: string
34
+ app: string
35
+ resource: string
36
+ scopes: string[]
37
+ createdAt: number
38
+ expiresAt: number
39
+ revokedAt: number | null
40
+ }
41
+
42
+ export interface OAuthAuthorizationRequest {
43
+ response_type: 'code'
44
+ client_id: string
45
+ redirect_uri: string
46
+ resource: string
47
+ scope?: string
48
+ state?: string
49
+ code_challenge: string
50
+ code_challenge_method: 'S256'
51
+ }
52
+
53
+ export type OAuthAuthorizationCoordinates = Omit<OAuthAuthorizationRequest, 'scope'>
54
+
55
+ export type OAuthAuthorizationParseResult =
56
+ | { kind: 'valid'; request: OAuthAuthorizationRequest }
57
+ | { kind: 'invalid_scope'; coordinates: OAuthAuthorizationCoordinates; rawScope: string }
58
+ | { kind: 'invalid_request' }
59
+
60
+ export type OAuthTokenRequest =
61
+ | { grant_type: 'authorization_code'; client_id: string; code: string; redirect_uri: string; code_verifier: string; resource: string }
62
+ | { grant_type: 'refresh_token'; client_id: string; refresh_token: string; resource: string }
63
+
64
+ export interface OAuthTokenResponse {
65
+ access_token: string
66
+ token_type: 'Bearer'
67
+ expires_in: number
68
+ refresh_token?: string
69
+ scope: string
70
+ }
71
+
72
+ export interface OAuthRevocationRequest {
73
+ client_id: string
74
+ token: string
75
+ token_type_hint?: 'access_token' | 'refresh_token'
76
+ }
77
+
78
+ export interface OAuthAuthorizationServerMetadata {
79
+ issuer: string
80
+ authorization_endpoint: string
81
+ token_endpoint: string
82
+ revocation_endpoint: string
83
+ jwks_uri: string
84
+ response_types_supported: ['code']
85
+ grant_types_supported: ['authorization_code', 'refresh_token']
86
+ code_challenge_methods_supported: ['S256']
87
+ token_endpoint_auth_methods_supported: ['none']
88
+ client_id_metadata_document_supported: true
89
+ }
90
+
91
+ function isNonemptyText(value: unknown): value is string {
92
+ return typeof value === 'string' && value.length > 0
93
+ }
94
+
95
+ function field(value: unknown, name: string): unknown {
96
+ return typeof value === 'object' && value !== null && Object.hasOwn(value, name) ? Reflect.get(value, name) : undefined
97
+ }
98
+
99
+ function redirectUris(value: unknown): string[] | null {
100
+ if (!Array.isArray(value) || value.length === 0 || value.length > 32) return null
101
+ const uris: string[] = []
102
+ for (const item of value) {
103
+ if (!isOAuthResourceUri(item) || uris.includes(item)) return null
104
+ uris.push(item)
105
+ }
106
+ return uris
107
+ }
108
+
109
+ export function parseOAuthManagedClient(value: unknown): OAuthManagedClient | null {
110
+ const provider = field(value, 'provider')
111
+ const clientId = field(value, 'clientId')
112
+ const uris = redirectUris(field(value, 'redirectUris'))
113
+ return (provider === 'chatgpt' || provider === 'claude') && isCimdClientId(clientId) && uris !== null
114
+ ? { provider, clientId, redirectUris: uris }
115
+ : null
116
+ }
117
+
118
+ /** A fetched document cannot enlarge the managed registration's exact redirect allowlist. */
119
+ export function parseOAuthClientMetadata(value: unknown, registration: OAuthManagedClient): OAuthClientMetadataDocument | null {
120
+ const managed = parseOAuthManagedClient(registration)
121
+ const clientId = field(value, 'client_id')
122
+ const clientName = field(value, 'client_name')
123
+ const uris = redirectUris(field(value, 'redirect_uris'))
124
+ const grants = parseClientGrantTypes(field(value, 'grant_types'))
125
+ const rawResponses = field(value, 'response_types')
126
+ const responses = rawResponses === undefined ? ['code'] : rawResponses
127
+ if (
128
+ 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))
130
+ || field(value, 'client_secret') !== undefined || field(value, 'client_secret_expires_at') !== undefined || grants === null
131
+ || !Array.isArray(responses) || responses.length !== 1 || responses[0] !== 'code'
132
+ ) return null
133
+ const metadata: OAuthClientMetadataDocument = {
134
+ client_id: managed.clientId,
135
+ redirect_uris: uris,
136
+ token_endpoint_auth_method: 'none',
137
+ grant_types: grants,
138
+ response_types: ['code'],
139
+ }
140
+ if (isNonemptyText(clientName)) metadata.client_name = clientName
141
+ return metadata
142
+ }
143
+
144
+ function parseClientGrantTypes(value: unknown): OAuthClientMetadataDocument['grant_types'] | null {
145
+ if (value === undefined) return ['authorization_code']
146
+ if (!Array.isArray(value) || value.length === 0 || value.length > 2) return null
147
+ const grants: OAuthClientMetadataDocument['grant_types'] = []
148
+ for (const grant of value) {
149
+ if ((grant !== 'authorization_code' && grant !== 'refresh_token') || grants.includes(grant)) return null
150
+ grants.push(grant)
151
+ }
152
+ return grants.includes('authorization_code') ? grants : null
153
+ }
154
+
155
+ export function parseOAuthResourceDto(value: unknown): OAuthResourceDto | null {
156
+ const app = field(value, 'app')
157
+ const resource = field(value, 'resource')
158
+ const rawGrants = field(value, 'scopeGrants')
159
+ const rawDefaultScopes = field(value, 'defaultScopes')
160
+ if (
161
+ !isNonemptyText(app) || app.includes('*') || !isOAuthResourceUri(resource) || !Array.isArray(rawGrants) || rawGrants.length === 0
162
+ || rawGrants.length > 64
163
+ ) return null
164
+ const scopeGrants: OAuthResourceDto['scopeGrants'] = []
165
+ for (const entry of rawGrants) {
166
+ const scope = field(entry, 'scope')
167
+ const permissions = parseResourcePermissions(field(entry, 'permissions'))
168
+ if (typeof scope !== 'string' || parseOAuthScopes([scope]) === null || permissions === null || scopeGrants.some((grant) => grant.scope === scope)) {
169
+ return null
170
+ }
171
+ scopeGrants.push({ scope, permissions })
172
+ }
173
+ const dto: OAuthResourceDto = { app, resource, scopeGrants }
174
+ if (rawDefaultScopes !== undefined) {
175
+ const defaultScopes = parseOAuthScopes(rawDefaultScopes)
176
+ if (defaultScopes === null || !defaultScopes.every((scope) => scopeGrants.some((grant) => grant.scope === scope))) return null
177
+ dto.defaultScopes = defaultScopes
178
+ }
179
+ return dto
180
+ }
181
+
182
+ function parseResourcePermissions(value: unknown): KeyGrant[] | null {
183
+ if (!Array.isArray(value) || value.length === 0 || value.length > 64) return null
184
+ const permissions: KeyGrant[] = []
185
+ for (const entry of value) {
186
+ const action = field(entry, 'action')
187
+ const rawScope = field(entry, 'scope')
188
+ if (!isNonemptyText(action)) return null
189
+ if (rawScope === undefined) permissions.push({ action })
190
+ else if (rawScope === null) permissions.push({ action, scope: null })
191
+ else {
192
+ const type = field(rawScope, 'type')
193
+ const scopeValue = field(rawScope, 'value')
194
+ if (!isNonemptyText(type) || !isNonemptyText(scopeValue)) return null
195
+ permissions.push({ action, scope: { type, value: scopeValue } })
196
+ }
197
+ }
198
+ return permissions
199
+ }
200
+
201
+ export function resolveOAuthAuthorizationScopes(scope: string | undefined, resource: OAuthResourceDto): string[] | null {
202
+ const scopes = scope === undefined ? resource.defaultScopes : parseOAuthScopes(scope.split(' '))
203
+ if (!scopes || scopes.length === 0) return null
204
+ return scopes.every((entry) => resource.scopeGrants.some((grant) => grant.scope === entry))
205
+ ? [...scopes]
206
+ : null
207
+ }
208
+
209
+ export function parseOAuthAuthorizationAttempt(value: unknown): OAuthAuthorizationParseResult {
210
+ const clientId = field(value, 'client_id')
211
+ const redirectUri = field(value, 'redirect_uri')
212
+ const resource = field(value, 'resource')
213
+ const scope = field(value, 'scope')
214
+ const state = field(value, 'state')
215
+ const challenge = field(value, 'code_challenge')
216
+ if (
217
+ field(value, 'response_type') !== 'code' || field(value, 'code_challenge_method') !== 'S256' || !isCimdClientId(clientId)
218
+ || !isOAuthResourceUri(redirectUri) || !isOAuthResourceUri(resource)
219
+ || (state !== undefined && (typeof state !== 'string' || state.length > 2048)) || typeof challenge !== 'string'
220
+ || !/^[A-Za-z0-9_-]{43}$/.test(challenge)
221
+ ) return { kind: 'invalid_request' }
222
+ if (scope !== undefined && typeof scope !== 'string') return { kind: 'invalid_request' }
223
+ const coordinates: OAuthAuthorizationCoordinates = {
224
+ response_type: 'code',
225
+ client_id: clientId,
226
+ redirect_uri: redirectUri,
227
+ resource,
228
+ code_challenge: challenge,
229
+ code_challenge_method: 'S256',
230
+ }
231
+ if (typeof state === 'string') coordinates.state = state
232
+ if (typeof scope === 'string' && parseOAuthScopes(scope.split(' ')) === null) return { kind: 'invalid_scope', coordinates, rawScope: scope }
233
+ const request: OAuthAuthorizationRequest = { ...coordinates }
234
+ if (typeof scope === 'string') request.scope = scope
235
+ return { kind: 'valid', request }
236
+ }
237
+
238
+ export function parseOAuthAuthorizationRequest(value: unknown): OAuthAuthorizationRequest | null {
239
+ const result = parseOAuthAuthorizationAttempt(value)
240
+ return result.kind === 'valid' ? result.request : null
241
+ }
242
+
243
+ export function parseOAuthTokenRequest(value: unknown): OAuthTokenRequest | null {
244
+ const clientId = field(value, 'client_id')
245
+ const resource = field(value, 'resource')
246
+ const grantType = field(value, 'grant_type')
247
+ if (!isCimdClientId(clientId) || !isOAuthResourceUri(resource)) return null
248
+ if (grantType === 'authorization_code') {
249
+ const code = field(value, 'code')
250
+ const redirectUri = field(value, 'redirect_uri')
251
+ const verifier = field(value, 'code_verifier')
252
+ if (!isNonemptyText(code) || !isOAuthResourceUri(redirectUri) || typeof verifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) {
253
+ return null
254
+ }
255
+ return { grant_type: grantType, client_id: clientId, code, redirect_uri: redirectUri, code_verifier: verifier, resource }
256
+ }
257
+ if (grantType === 'refresh_token') {
258
+ const refreshToken = field(value, 'refresh_token')
259
+ return isNonemptyText(refreshToken) ? { grant_type: grantType, client_id: clientId, refresh_token: refreshToken, resource } : null
260
+ }
261
+ return null
262
+ }
263
+
264
+ export function parseOAuthRevocationRequest(value: unknown): OAuthRevocationRequest | null {
265
+ const clientId = field(value, 'client_id')
266
+ const token = field(value, 'token')
267
+ const hint = field(value, 'token_type_hint')
268
+ if (
269
+ !isCimdClientId(clientId) || typeof token !== 'string' || token.length === 0 || token.length > 32768
270
+ || (hint !== undefined && (typeof hint !== 'string' || hint.length > 200))
271
+ ) return null
272
+ const request: OAuthRevocationRequest = { client_id: clientId, token }
273
+ if (hint === 'access_token' || hint === 'refresh_token') request.token_type_hint = hint
274
+ return request
275
+ }