@fabrika/iam-contract 0.8.1 → 0.9.0-cloudflare.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 +7 -5
- package/src/access.ts +3 -0
- package/src/index.ts +160 -13
- package/src/oauth.ts +71 -10
- package/src/password-policy.ts +43 -0
- package/types/src/access.d.ts +27 -0
- package/types/src/index.d.ts +745 -0
- package/types/src/oauth.d.ts +136 -0
- package/types/src/password-policy.d.ts +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/iam-contract",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-cloudflare.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
"access": "public"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
|
-
"src"
|
|
14
|
+
"src",
|
|
15
|
+
"types"
|
|
15
16
|
],
|
|
16
17
|
"exports": {
|
|
17
18
|
".": {
|
|
18
19
|
"bun": "./src/index.ts",
|
|
19
|
-
"types": "./src/index.ts",
|
|
20
|
+
"types": "./types/src/index.d.ts",
|
|
20
21
|
"default": "./src/index.ts"
|
|
21
22
|
}
|
|
22
23
|
},
|
|
@@ -24,7 +25,8 @@
|
|
|
24
25
|
"typecheck": "tsc --noEmit"
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
|
-
"@fabrika/app": "0.
|
|
28
|
-
"@fabrika/auth-core": "0.
|
|
28
|
+
"@fabrika/app": "0.9.0-cloudflare.1",
|
|
29
|
+
"@fabrika/auth-core": "0.9.0-cloudflare.1",
|
|
30
|
+
"@fabrika/proxy-core": "0.9.0-cloudflare.1"
|
|
29
31
|
}
|
|
30
32
|
}
|
package/src/access.ts
CHANGED
|
@@ -11,6 +11,8 @@ export const IAM_ACTIONS = Object.freeze({
|
|
|
11
11
|
ADMIN: 'iam.admin',
|
|
12
12
|
/** Manage the people of applications, never of the installation's own apps. Global, or in `app` values. */
|
|
13
13
|
USERS_MANAGE: 'iam.users.manage',
|
|
14
|
+
/** Enrol a browser as a terminal (ADR-0067). Global only; its profile and revocation stay `iam.admin`'s. */
|
|
15
|
+
TERMINALS_MANAGE: 'iam.terminals.manage',
|
|
14
16
|
})
|
|
15
17
|
|
|
16
18
|
export const IAM_SCOPES = Object.freeze({
|
|
@@ -40,6 +42,7 @@ export const IAM_APP_SCHEMA: AppSchema = {
|
|
|
40
42
|
actions: [
|
|
41
43
|
{ action: IAM_ACTIONS.ADMIN, description: 'Administer identity and access for the installation' },
|
|
42
44
|
{ action: IAM_ACTIONS.USERS_MANAGE, description: 'Manage the people of applications: invite, sign out, reset, grant and revoke' },
|
|
45
|
+
{ action: IAM_ACTIONS.TERMINALS_MANAGE, description: 'Enrol a shared computer as a terminal' },
|
|
43
46
|
],
|
|
44
47
|
roles: {
|
|
45
48
|
[USER_ADMIN_ROLE]: {
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type {
|
|
|
24
24
|
RoleDef,
|
|
25
25
|
WorkloadKind,
|
|
26
26
|
} from '@fabrika/auth-core'
|
|
27
|
+
import type { InstallationBrand } from '@fabrika/proxy-core/brand'
|
|
28
|
+
import type { PasswordPolicyDto, SetPasswordPolicyRequest } from './password-policy'
|
|
27
29
|
|
|
28
30
|
// ── Common wrappers ───────────────────────────────────────────────────────────
|
|
29
31
|
|
|
@@ -81,17 +83,40 @@ export interface GrantDto {
|
|
|
81
83
|
export interface PrincipalDetail extends PrincipalListItem {
|
|
82
84
|
/** The mailbox is listed in `IAM_BOOTSTRAP_ADMINS`: an internal user with it is an installation admin. */
|
|
83
85
|
bootstrapMailbox: boolean
|
|
84
|
-
grants:
|
|
85
|
-
/**
|
|
86
|
+
grants: PrincipalGrantDto[]
|
|
87
|
+
/** Permissions at IAM only. Prefer `access` for the per-application view. */
|
|
86
88
|
permissions: PermissionEntry[]
|
|
89
|
+
access: PrincipalAppAccess[]
|
|
90
|
+
/** Presentation capabilities. Every mutation still checks the caller live. */
|
|
91
|
+
management: { account: boolean; passwordLinks: boolean; audit: boolean }
|
|
92
|
+
activatedAt: number | null
|
|
93
|
+
disabledAt: number | null
|
|
87
94
|
authentication: {
|
|
88
95
|
/** OIDC is linked when this user has claimed an identity-provider subject. */
|
|
89
96
|
oidc: { state: 'unavailable' | 'unlinked' | 'linked' }
|
|
90
97
|
/** Password access is globally opt-in and then enabled per user. */
|
|
91
|
-
password: {
|
|
98
|
+
password: {
|
|
99
|
+
state: 'unavailable' | 'disabled' | 'pending' | 'enabled'
|
|
100
|
+
delivery: 'email' | 'manual'
|
|
101
|
+
latestAction: { purpose: 'enrollment' | 'reset'; createdAt: number; expiresAt: number; closedAt: number | null } | null
|
|
102
|
+
}
|
|
92
103
|
}
|
|
93
104
|
}
|
|
94
105
|
|
|
106
|
+
export interface PrincipalGrantDto extends GrantDto {
|
|
107
|
+
roleName: string | null
|
|
108
|
+
rolePermissions: string[]
|
|
109
|
+
grantedByLabel: string | null
|
|
110
|
+
canManage: boolean
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PrincipalAppAccess {
|
|
114
|
+
app: string
|
|
115
|
+
/** Resolved by IAM using the same per-app resolver as token issuance; empty for a disabled account. */
|
|
116
|
+
permissions: PermissionEntry[]
|
|
117
|
+
lastSignInAt: number | null
|
|
118
|
+
}
|
|
119
|
+
|
|
95
120
|
export interface InviteRequest {
|
|
96
121
|
email: string
|
|
97
122
|
/** Omitted: `internal`. An `external` user signs in by password only, and OIDC never claims it. */
|
|
@@ -111,8 +136,11 @@ export interface UpdatePrincipalRequest {
|
|
|
111
136
|
export interface SessionDto {
|
|
112
137
|
id: string
|
|
113
138
|
principalId: string
|
|
114
|
-
/**
|
|
115
|
-
|
|
139
|
+
/**
|
|
140
|
+
* How the human proved who they were; `local_dev` is IAM's `LOCAL_DEV_LOGIN` bypass, `terminal_assertion` an
|
|
141
|
+
* application's word about who stands at an enrolled terminal.
|
|
142
|
+
*/
|
|
143
|
+
authenticationMethod: 'oidc' | 'password' | 'local_dev' | 'terminal_assertion'
|
|
116
144
|
/** The app this session is bound to (an ADR-0022 child); null = the IAM session itself. */
|
|
117
145
|
app: string | null
|
|
118
146
|
/** The IAM session this was derived from; null on an IAM session. Revoking that one revokes this. */
|
|
@@ -122,6 +150,13 @@ export interface SessionDto {
|
|
|
122
150
|
createdAt: number
|
|
123
151
|
expiresAt: number
|
|
124
152
|
revokedAt: number | null
|
|
153
|
+
/** Why this session cannot be used now, independently of its own stored lifecycle. */
|
|
154
|
+
unavailableReason: 'revoked' | 'expired' | 'parent_ended' | 'account_disabled' | 'method_disabled' | null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface SessionList extends CursorList<SessionDto> {
|
|
158
|
+
/** App sessions belonging to the sign-ins on this page, when `signInsOnly` is requested. */
|
|
159
|
+
applications: SessionDto[]
|
|
125
160
|
}
|
|
126
161
|
|
|
127
162
|
// ── Grants ────────────────────────────────────────────────────────────────────
|
|
@@ -141,6 +176,11 @@ export interface CreateGrantRequest {
|
|
|
141
176
|
expiresAt?: number | null
|
|
142
177
|
}
|
|
143
178
|
|
|
179
|
+
/** Change access within its original application, preserving the grant's identity and author. */
|
|
180
|
+
export interface UpdateGrantInput extends Omit<CreateGrantRequest, 'principalId' | 'app'> {
|
|
181
|
+
id: string
|
|
182
|
+
}
|
|
183
|
+
|
|
144
184
|
// ── Apps (read-only; the live registry derived from the schema tables) ────────
|
|
145
185
|
|
|
146
186
|
/** The set of app ids propustka knows about — the choices for a grant's `app`. */
|
|
@@ -155,11 +195,8 @@ export interface AppDto {
|
|
|
155
195
|
// ── Roles & policies (per-app, DB-backed) ─────────────────────────────────────
|
|
156
196
|
|
|
157
197
|
/** A role available for the calling app — a built-in or a DB row (app/custom). */
|
|
158
|
-
export interface RoleDto {
|
|
198
|
+
export interface RoleDto extends RoleDef {
|
|
159
199
|
key: string
|
|
160
|
-
name: string
|
|
161
|
-
description?: string
|
|
162
|
-
permissions: string[]
|
|
163
200
|
/** 'builtin' (cross-app, e.g. admin), 'app' (reconciled), or 'custom' (admin-made). */
|
|
164
201
|
origin: 'builtin' | 'app' | 'custom'
|
|
165
202
|
}
|
|
@@ -192,6 +229,8 @@ export interface AppSchemaDto {
|
|
|
192
229
|
members?: { manage: string; list: string }
|
|
193
230
|
/** `labels` when the app's directory shows labels to `members.list` holders. */
|
|
194
231
|
directory?: 'labels'
|
|
232
|
+
/** The app's identification page at a terminal (ADR-0067); absent when the app declares none. */
|
|
233
|
+
terminal?: { identify: string }
|
|
195
234
|
}
|
|
196
235
|
|
|
197
236
|
// ── Policies (origin='custom' roles, admin-composed) ──────────────────────────
|
|
@@ -231,8 +270,9 @@ export interface ApiKeyDto {
|
|
|
231
270
|
}
|
|
232
271
|
|
|
233
272
|
/**
|
|
234
|
-
* One API key with its live credential
|
|
235
|
-
* `grants.delete` by an installation administrator and read again at every
|
|
273
|
+
* One API key with its live credential, as `apiKeys.get` and each row of `apiKeys.list` answer it. Its permissions are
|
|
274
|
+
* its grants, edited with `grants.create` and `grants.delete` by an installation administrator and read again at every
|
|
275
|
+
* token exchange. An IAM older than the list's credential answers list rows without `credential`.
|
|
236
276
|
*/
|
|
237
277
|
export interface ApiKeyDetail extends ApiKeyDto {
|
|
238
278
|
/** The key's live credential, or null when none is live (a revoked key). */
|
|
@@ -398,6 +438,7 @@ export interface PrincipalIdInput {
|
|
|
398
438
|
export interface ListSessionsInput extends PageInput {
|
|
399
439
|
/** Whose sessions to list. */
|
|
400
440
|
principalId: string
|
|
441
|
+
signInsOnly?: boolean
|
|
401
442
|
}
|
|
402
443
|
|
|
403
444
|
export interface SessionIdInput {
|
|
@@ -471,7 +512,14 @@ export interface ListAuditInput {
|
|
|
471
512
|
resourceType?: string
|
|
472
513
|
resourceId?: string
|
|
473
514
|
principalId?: string
|
|
515
|
+
/** The person affected by the change, distinct from `principalId` (the actor). */
|
|
516
|
+
subjectPrincipalId?: string
|
|
474
517
|
action?: string
|
|
518
|
+
/**
|
|
519
|
+
* Leave out the admin reads (`iam.admin.access`), which IAM writes for every `/admin/*` request, so a feed of what
|
|
520
|
+
* changed reads changes and not the reads that page through it. An IAM older than this field ignores it.
|
|
521
|
+
*/
|
|
522
|
+
changesOnly?: boolean
|
|
475
523
|
requestId?: string
|
|
476
524
|
before?: string
|
|
477
525
|
limit?: number
|
|
@@ -557,6 +605,45 @@ export interface IssueMembershipKeyResponse {
|
|
|
557
605
|
expiresAt: number
|
|
558
606
|
}
|
|
559
607
|
|
|
608
|
+
// ── Terminals (ADR-0067) ──────────────────────────────────────────────────────
|
|
609
|
+
|
|
610
|
+
/** An application that may assert a person on a terminal, with the action patterns of the person that survive there. */
|
|
611
|
+
export interface TerminalAsserterDto {
|
|
612
|
+
app: string
|
|
613
|
+
ceiling: string[]
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* An enrolled browser, as an operator sees it. Its own grants are ordinary grants on `terminalId`, which is its service
|
|
618
|
+
* principal; the cookie is never shown.
|
|
619
|
+
*/
|
|
620
|
+
export interface TerminalDto {
|
|
621
|
+
terminalId: string
|
|
622
|
+
label: string
|
|
623
|
+
/** The administrator who enrolled it; null once that principal is gone. */
|
|
624
|
+
enrolledBy: string | null
|
|
625
|
+
/** Seconds a person's session on it lasts. */
|
|
626
|
+
sessionTtl: number
|
|
627
|
+
asserters: TerminalAsserterDto[]
|
|
628
|
+
status: 'active' | 'revoked'
|
|
629
|
+
createdAt: number
|
|
630
|
+
lastSeenAt: number | null
|
|
631
|
+
revokedAt: number | null
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export type ListTerminalsInput = PageInput
|
|
635
|
+
|
|
636
|
+
export interface TerminalIdInput {
|
|
637
|
+
terminalId: string
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Replace a terminal's whole profile. The ceiling is empty by default: the person brings only their identity. */
|
|
641
|
+
export interface SetTerminalProfileRequest extends TerminalIdInput {
|
|
642
|
+
label: string
|
|
643
|
+
sessionTtl: number
|
|
644
|
+
asserters: TerminalAsserterDto[]
|
|
645
|
+
}
|
|
646
|
+
|
|
560
647
|
// ── An application's own people, administered by its own administrator ───────
|
|
561
648
|
|
|
562
649
|
/**
|
|
@@ -612,6 +699,21 @@ export interface RevokeAppRoleResponse {
|
|
|
612
699
|
revoked: boolean
|
|
613
700
|
}
|
|
614
701
|
|
|
702
|
+
/**
|
|
703
|
+
* The installation's client logo on fabrika's own pages (ADR-0066): IAM's sign-in, account and invitation pages, and the
|
|
704
|
+
* proxy's error pages on every application host. `null` shows the fabrika mark. An installation serves one client, so
|
|
705
|
+
* this is one setting of the installation, kept by IAM.
|
|
706
|
+
*/
|
|
707
|
+
export interface InstallationBrandDto {
|
|
708
|
+
brand: InstallationBrand | null
|
|
709
|
+
/** When it was last set or cleared, in epoch seconds; null when it never was. */
|
|
710
|
+
updatedAt: number | null
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export interface SetInstallationBrandRequest {
|
|
714
|
+
brand: InstallationBrand
|
|
715
|
+
}
|
|
716
|
+
|
|
615
717
|
export interface IamAdminRpcContract {
|
|
616
718
|
me: RpcProcedure<void, MeDto>
|
|
617
719
|
principals: {
|
|
@@ -630,7 +732,7 @@ export interface IamAdminRpcContract {
|
|
|
630
732
|
* derived from it — a child is only valid while its parent is — so this is one call, not a sweep.
|
|
631
733
|
*/
|
|
632
734
|
sessions: {
|
|
633
|
-
list: RpcProcedure<ListSessionsInput,
|
|
735
|
+
list: RpcProcedure<ListSessionsInput, SessionList>
|
|
634
736
|
revoke: RpcProcedure<SessionIdInput, RevokedSessionsResponse>
|
|
635
737
|
revokeAll: RpcProcedure<PrincipalIdInput, RevokedSessionsResponse>
|
|
636
738
|
}
|
|
@@ -642,8 +744,13 @@ export interface IamAdminRpcContract {
|
|
|
642
744
|
grants: {
|
|
643
745
|
/** A role grant the principal already holds — same role, scope and app — answers 409 naming it. */
|
|
644
746
|
create: RpcProcedure<CreateGrantRequest, GrantDto>
|
|
747
|
+
update: RpcProcedure<UpdateGrantInput, GrantDto>
|
|
645
748
|
delete: RpcProcedure<GrantIdInput, OkResponse>
|
|
646
749
|
}
|
|
750
|
+
connections: {
|
|
751
|
+
list: RpcProcedure<ListSessionsInput, CursorList<UserConnectionDto>>
|
|
752
|
+
revoke: RpcProcedure<GrantIdInput, OkResponse>
|
|
753
|
+
}
|
|
647
754
|
apps: {
|
|
648
755
|
list: RpcProcedure<void, ListResponse<AppDto>>
|
|
649
756
|
getSchema: RpcProcedure<AppInput, AppSchemaDto>
|
|
@@ -659,7 +766,7 @@ export interface IamAdminRpcContract {
|
|
|
659
766
|
delete: RpcProcedure<PolicyIdInput, OkResponse>
|
|
660
767
|
}
|
|
661
768
|
apiKeys: {
|
|
662
|
-
list: RpcProcedure<ListApiKeysInput, CursorList<
|
|
769
|
+
list: RpcProcedure<ListApiKeysInput, CursorList<ApiKeyDetail>>
|
|
663
770
|
get: RpcProcedure<ApiKeyIdInput, ApiKeyDetail>
|
|
664
771
|
provision: RpcProcedure<ProvisionApiKeyRequest, ProvisionApiKeyResponse>
|
|
665
772
|
rotate: RpcProcedure<RotateApiKeyInput, RotateApiKeyResponse>
|
|
@@ -681,6 +788,15 @@ export interface IamAdminRpcContract {
|
|
|
681
788
|
list: RpcProcedure<ListAuditInput, CursorList<AuditEventDto>>
|
|
682
789
|
listAuthLog: RpcProcedure<ListAuthLogInput, CursorList<AuthLogDto>>
|
|
683
790
|
}
|
|
791
|
+
/**
|
|
792
|
+
* Enrolled terminals. A browser is enrolled at IAM's `/auth/terminals/enroll`, never here. Revoking a terminal ends
|
|
793
|
+
* every session issued on it.
|
|
794
|
+
*/
|
|
795
|
+
terminals: {
|
|
796
|
+
list: RpcProcedure<ListTerminalsInput, CursorList<TerminalDto>>
|
|
797
|
+
setProfile: RpcProcedure<SetTerminalProfileRequest, TerminalDto>
|
|
798
|
+
revoke: RpcProcedure<TerminalIdInput, OkResponse>
|
|
799
|
+
}
|
|
684
800
|
/**
|
|
685
801
|
* An application's own people, administered by whoever holds the application's `members.manage` globally there: no
|
|
686
802
|
* installation admin needed. Each change writes its audit row under the application.
|
|
@@ -691,8 +807,39 @@ export interface IamAdminRpcContract {
|
|
|
691
807
|
grant: RpcProcedure<GrantAppRoleRequest, GrantAppRoleResponse>
|
|
692
808
|
revoke: RpcProcedure<RevokeAppRoleRequest, RevokeAppRoleResponse>
|
|
693
809
|
}
|
|
810
|
+
/**
|
|
811
|
+
* The installation's client logo (ADR-0066), an installation administrator's (`iam.admin`) to read and to change. A
|
|
812
|
+
* change writes `iam.brand.update` in its own transaction. IAM's pages show it on their next render; the proxy's
|
|
813
|
+
* pages once Control republishes their manifests.
|
|
814
|
+
*/
|
|
815
|
+
brand: {
|
|
816
|
+
get: RpcProcedure<void, InstallationBrandDto>
|
|
817
|
+
set: RpcProcedure<SetInstallationBrandRequest, InstallationBrandDto>
|
|
818
|
+
clear: RpcProcedure<void, InstallationBrandDto>
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* The installation's minimum password length (ADR-0020), an installation administrator's (`iam.admin`) to read and to
|
|
822
|
+
* change. A change writes `iam.password_policy.update` in its own transaction and applies to the next password set,
|
|
823
|
+
* reset or chosen at an invitation; stored passwords stay as they are.
|
|
824
|
+
*/
|
|
825
|
+
passwordPolicy: {
|
|
826
|
+
get: RpcProcedure<void, PasswordPolicyDto>
|
|
827
|
+
set: RpcProcedure<SetPasswordPolicyRequest, PasswordPolicyDto>
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export interface UserConnectionDto {
|
|
832
|
+
id: string
|
|
833
|
+
clientId: string
|
|
834
|
+
app: string
|
|
835
|
+
resource: string
|
|
836
|
+
scopes: string[]
|
|
837
|
+
createdAt: number
|
|
838
|
+
expiresAt: number
|
|
839
|
+
status: 'active' | 'revoked' | 'expired' | 'unavailable'
|
|
694
840
|
}
|
|
695
841
|
|
|
696
842
|
export type { AppActionDef, AppMemberItem, AppSchema, AppScopeDef, PermissionEntry, PermissionSource, PrincipalAffiliation, PrincipalType, RoleDef }
|
|
697
843
|
export * from './access'
|
|
698
844
|
export * from './oauth'
|
|
845
|
+
export * from './password-policy'
|
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')
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The installation's password policy (ADR-0020): one minimum length for every principal of the installation, which an
|
|
2
|
+
// installation administrator may lower from the default down to the runtime's floor. IAM checks every value again; the
|
|
3
|
+
// console reads these numbers only to say what IAM will accept.
|
|
4
|
+
|
|
5
|
+
/** The minimum of an installation that never set one: NIST SP 800-63B's for a password that is the only factor. */
|
|
6
|
+
export const PASSWORD_DEFAULT_MIN_LENGTH = 15
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The lowest minimum IAM accepts on a runtime whose verifiers meet OWASP's PBKDF2 cost. Below it a password is a PIN, and
|
|
10
|
+
* no throttle makes it one a person can be trusted with.
|
|
11
|
+
*/
|
|
12
|
+
export const PASSWORD_MIN_LENGTH_FLOOR = 4
|
|
13
|
+
|
|
14
|
+
/** The most code points a password may have, whatever the minimum. */
|
|
15
|
+
export const PASSWORD_MAX_LENGTH = 256
|
|
16
|
+
|
|
17
|
+
/** Below this NIST asks for a second factor, which IAM does not have; the console says so. */
|
|
18
|
+
export const PASSWORD_SINGLE_FACTOR_FLOOR = 8
|
|
19
|
+
|
|
20
|
+
/** The password policy as IAM applies it now. */
|
|
21
|
+
export interface PasswordPolicyDto {
|
|
22
|
+
/** The minimum length in Unicode code points that IAM applies to every new password. */
|
|
23
|
+
minLength: number
|
|
24
|
+
/** The minimum of an installation that never set one, `PASSWORD_DEFAULT_MIN_LENGTH`. */
|
|
25
|
+
defaultMinLength: number
|
|
26
|
+
/**
|
|
27
|
+
* The lowest minimum this IAM accepts. `PASSWORD_MIN_LENGTH_FLOOR` where verifiers meet OWASP's PBKDF2 cost; the
|
|
28
|
+
* default on a runtime whose cost is lower (a Cloudflare Worker, backlog 201), where a short password is cheap to
|
|
29
|
+
* crack offline.
|
|
30
|
+
*/
|
|
31
|
+
minLengthFloor: number
|
|
32
|
+
/** The most code points a password may have. */
|
|
33
|
+
maxLength: number
|
|
34
|
+
/** Whether the installation enables password sign-in at all; the minimum waits for it when it does not. */
|
|
35
|
+
passwordEnabled: boolean
|
|
36
|
+
/** When an administrator last changed the minimum, in epoch seconds; null when nobody ever did. */
|
|
37
|
+
updatedAt: number | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SetPasswordPolicyRequest {
|
|
41
|
+
/** A whole number from `minLengthFloor` to `maxLength`. */
|
|
42
|
+
minLength: number
|
|
43
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type AppSchema, type PermissionEntry } from '@fabrika/auth-core';
|
|
2
|
+
/** Durable IAM application id of IAM itself — a deployed identity, not configuration branding (ADR-0024). */
|
|
3
|
+
export declare const IAM_APP_ID = "propustka";
|
|
4
|
+
export declare const IAM_ACTIONS: Readonly<{
|
|
5
|
+
/** Administer identity and access for the installation. Global only; it reaches every application. */
|
|
6
|
+
ADMIN: "iam.admin";
|
|
7
|
+
/** Manage the people of applications, never of the installation's own apps. Global, or in `app` values. */
|
|
8
|
+
USERS_MANAGE: "iam.users.manage";
|
|
9
|
+
/** Enrol a browser as a terminal (ADR-0067). Global only; its profile and revocation stay `iam.admin`'s. */
|
|
10
|
+
TERMINALS_MANAGE: "iam.terminals.manage";
|
|
11
|
+
}>;
|
|
12
|
+
export declare const IAM_SCOPES: Readonly<{
|
|
13
|
+
/** One application environment, by its IAM id `<app>@<env>`. */
|
|
14
|
+
APP: "app";
|
|
15
|
+
}>;
|
|
16
|
+
/** The preset role a user administrator holds. */
|
|
17
|
+
export declare const USER_ADMIN_ROLE = "user-admin";
|
|
18
|
+
/** What a holder of IAM permissions administers: the installation, or the people of some applications (`null` = all). */
|
|
19
|
+
export type IamAdministrationScope = {
|
|
20
|
+
readonly kind: 'installation';
|
|
21
|
+
} | {
|
|
22
|
+
readonly kind: 'users';
|
|
23
|
+
readonly apps: readonly string[] | null;
|
|
24
|
+
};
|
|
25
|
+
/** The administration IAM permissions confer, or null for none. IAM adds that a user administrator acts from a session. */
|
|
26
|
+
export declare function iamAdministrationScope(permissions: PermissionEntry[]): IamAdministrationScope | null;
|
|
27
|
+
export declare const IAM_APP_SCHEMA: AppSchema;
|