@fabrika/iam-contract 0.3.3 → 0.4.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/index.ts +186 -5
- 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
|
+
"version": "0.4.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.4.1",
|
|
28
|
+
"@fabrika/auth-core": "^0.4.1"
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|