@_mustachio/openauth 0.11.0 → 0.13.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.
- package/dist/esm/adapters/d1/index.js +18 -9
- package/dist/esm/adapters/d1/migrations.js +7 -2
- package/dist/esm/adapters/d1/session-store.js +39 -0
- package/dist/esm/adapters/d1/session.js +2 -2
- package/dist/esm/adapters/durable-object/index.js +4 -0
- package/dist/esm/adapters/durable-object/session-store.js +42 -0
- package/dist/esm/adapters/dynamo/index.js +24 -7
- package/dist/esm/adapters/dynamo/session-store.js +49 -0
- package/dist/esm/adapters/kms/index.js +6 -2
- package/dist/esm/adapters/kv/index.js +6 -2
- package/dist/esm/adapters/memory/index.js +24 -7
- package/dist/esm/adapters/memory/session-store.js +23 -0
- package/dist/esm/adapters/postgres/executor.js +2 -2
- package/dist/esm/adapters/postgres/index.js +33 -10
- package/dist/esm/adapters/postgres/migrations.js +8 -2
- package/dist/esm/adapters/postgres/session-store.js +42 -0
- package/dist/esm/client.js +27 -27
- package/dist/esm/domain/authorize.js +2 -2
- package/dist/esm/domain/callback.js +121 -7
- package/dist/esm/domain/client-auth.js +2 -2
- package/dist/esm/domain/crypto.js +14 -14
- package/dist/esm/domain/discovery.js +2 -2
- package/dist/esm/domain/dpop.js +4 -4
- package/dist/esm/domain/id-token.js +5 -5
- package/dist/esm/domain/jwt.js +4 -4
- package/dist/esm/domain/method-dispatch.js +27 -1
- package/dist/esm/domain/method-route.js +80 -1
- package/dist/esm/domain/par.js +2 -2
- package/dist/esm/domain/pkce.js +2 -2
- package/dist/esm/domain/revoke.js +2 -2
- package/dist/esm/domain/scim/discovery.js +206 -0
- package/dist/esm/domain/scim/filter.js +177 -0
- package/dist/esm/domain/scim/handle.js +338 -0
- package/dist/esm/domain/scim/patch.js +568 -0
- package/dist/esm/domain/scim/resource.js +256 -0
- package/dist/esm/domain/state-envelope.js +2 -2
- package/dist/esm/domain/token.js +5 -5
- package/dist/esm/error.js +8 -8
- package/dist/esm/http/cookies.js +19 -2
- package/dist/esm/http/errors.js +4 -4
- package/dist/esm/http/handlers/authorize.js +3 -19
- package/dist/esm/http/handlers/callback.js +7 -1
- package/dist/esm/http/handlers/metadata.js +2 -2
- package/dist/esm/http/handlers/method-route.js +47 -2
- package/dist/esm/http/handlers/revocation.js +2 -2
- package/dist/esm/http/handlers/scim.js +59 -0
- package/dist/esm/http/middleware/tenant.js +31 -6
- package/dist/esm/http/router.js +3 -0
- package/dist/esm/http/schemas/authorize.js +2 -2
- package/dist/esm/http/schemas/revocation.js +2 -2
- package/dist/esm/http/schemas/token.js +7 -7
- package/dist/esm/index.js +34 -32
- package/dist/esm/methods/oauth2-factory.js +2 -2
- package/dist/esm/methods/password-hash.js +2 -2
- package/dist/esm/methods/providers/index.js +30 -14
- package/dist/esm/methods/saml-sp/acs.js +232 -0
- package/dist/esm/methods/saml-sp/attributes.js +77 -0
- package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
- package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
- package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
- package/dist/esm/methods/saml-sp/factory.js +81 -0
- package/dist/esm/methods/saml-sp/index.js +7 -0
- package/dist/esm/methods/saml-sp/metadata.js +71 -0
- package/dist/esm/methods/saml-sp/method.js +29 -0
- package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
- package/dist/esm/methods/saml-sp/saml-instance.js +59 -0
- package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
- package/dist/esm/methods/saml-sp/sls.js +154 -0
- package/dist/esm/methods/saml-sp/types.js +0 -0
- package/dist/esm/ports/scim-directory.js +0 -0
- package/dist/esm/types/error.js +5 -0
- package/dist/esm/types/result.js +3 -3
- package/dist/esm/types/scim.js +0 -0
- package/dist/esm/ui/forms.js +3 -3
- package/dist/esm/ui/index.js +8 -5
- package/dist/esm/ui/picker.js +2 -2
- package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
- package/dist/types/adapters/d1/session-store.d.ts +3 -0
- package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
- package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
- package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
- package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
- package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
- package/dist/types/adapters/memory/session-store.d.ts +3 -0
- package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
- package/dist/types/adapters/postgres/migrations.d.ts +1 -1
- package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
- package/dist/types/adapters/postgres/session-store.d.ts +3 -0
- package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
- package/dist/types/domain/callback.d.ts +16 -1
- package/dist/types/domain/callback.d.ts.map +1 -1
- package/dist/types/domain/method-dispatch.d.ts.map +1 -1
- package/dist/types/domain/method-route.d.ts +57 -1
- package/dist/types/domain/method-route.d.ts.map +1 -1
- package/dist/types/domain/scim/discovery.d.ts +11 -0
- package/dist/types/domain/scim/discovery.d.ts.map +1 -0
- package/dist/types/domain/scim/filter.d.ts +51 -0
- package/dist/types/domain/scim/filter.d.ts.map +1 -0
- package/dist/types/domain/scim/handle.d.ts +35 -0
- package/dist/types/domain/scim/handle.d.ts.map +1 -0
- package/dist/types/domain/scim/patch.d.ts +73 -0
- package/dist/types/domain/scim/patch.d.ts.map +1 -0
- package/dist/types/domain/scim/resource.d.ts +56 -0
- package/dist/types/domain/scim/resource.d.ts.map +1 -0
- package/dist/types/http/context.d.ts +6 -1
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/cookies.d.ts +9 -1
- package/dist/types/http/cookies.d.ts.map +1 -1
- package/dist/types/http/handlers/callback.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/http/handlers/scim.d.ts +3 -0
- package/dist/types/http/handlers/scim.d.ts.map +1 -0
- package/dist/types/http/middleware/tenant.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/acs.d.ts +4 -0
- package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/attributes.d.ts +45 -0
- package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/factory.d.ts +920 -0
- package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/index.d.ts +24 -0
- package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/metadata.d.ts +50 -0
- package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/method.d.ts +17 -0
- package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts +93 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/sls.d.ts +4 -0
- package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/types.d.ts +299 -0
- package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
- package/dist/types/ports/audit-log.d.ts +18 -5
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/ports/scim-directory.d.ts +104 -0
- package/dist/types/ports/scim-directory.d.ts.map +1 -0
- package/dist/types/ports/session-store.d.ts +23 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +5 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +83 -0
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/method.d.ts +109 -0
- package/dist/types/types/method.d.ts.map +1 -1
- package/dist/types/types/scim.d.ts +269 -0
- package/dist/types/types/scim.d.ts.map +1 -0
- package/dist/types/types/tenant.d.ts +10 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/adapters/d1/migrations.ts +5 -0
- package/src/adapters/d1/session-store.ts +68 -0
- package/src/adapters/durable-object/session-store.ts +61 -0
- package/src/adapters/dynamo/session-store.ts +63 -0
- package/src/adapters/memory/session-store.ts +39 -0
- package/src/adapters/postgres/migrations.ts +6 -0
- package/src/adapters/postgres/session-store.ts +69 -0
- package/src/domain/callback.ts +215 -7
- package/src/domain/method-dispatch.ts +53 -1
- package/src/domain/method-route.ts +190 -1
- package/src/domain/scim/discovery.ts +262 -0
- package/src/domain/scim/filter.ts +295 -0
- package/src/domain/scim/handle.ts +597 -0
- package/src/domain/scim/patch.ts +807 -0
- package/src/domain/scim/resource.ts +358 -0
- package/src/http/context.ts +9 -0
- package/src/http/cookies.ts +19 -1
- package/src/http/handlers/authorize.ts +2 -22
- package/src/http/handlers/callback.ts +9 -1
- package/src/http/handlers/method-route.ts +72 -2
- package/src/http/handlers/scim.ts +107 -0
- package/src/http/middleware/tenant.ts +47 -1
- package/src/http/router.ts +9 -0
- package/src/index.ts +23 -0
- package/src/methods/saml-sp/acs.ts +449 -0
- package/src/methods/saml-sp/attributes.ts +146 -0
- package/src/methods/saml-sp/authnrequest.ts +129 -0
- package/src/methods/saml-sp/cache-provider.ts +76 -0
- package/src/methods/saml-sp/cert-rotation.ts +34 -0
- package/src/methods/saml-sp/factory.ts +139 -0
- package/src/methods/saml-sp/index.ts +34 -0
- package/src/methods/saml-sp/metadata.ts +215 -0
- package/src/methods/saml-sp/method.ts +65 -0
- package/src/methods/saml-sp/parse-idp-metadata.ts +158 -0
- package/src/methods/saml-sp/saml-instance.ts +210 -0
- package/src/methods/saml-sp/slo-initiate.ts +180 -0
- package/src/methods/saml-sp/sls.ts +307 -0
- package/src/methods/saml-sp/types.ts +298 -0
- package/src/ports/CONSISTENCY.md +52 -24
- package/src/ports/audit-log.ts +18 -5
- package/src/ports/scim-directory.ts +166 -0
- package/src/ports/session-store.ts +24 -0
- package/src/types/error.ts +11 -0
- package/src/types/idp.ts +86 -0
- package/src/types/method.ts +110 -0
- package/src/types/scim.ts +280 -0
- package/src/types/tenant.ts +10 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCIM resource shapes — validation in, serialization out, and the
|
|
3
|
+
* error envelope.
|
|
4
|
+
*
|
|
5
|
+
* RFC 7643 defines the User schema; RFC 7644 defines the message
|
|
6
|
+
* envelopes. Both are picky in ways that are easy to get subtly wrong
|
|
7
|
+
* and that Okta's validator checks, so the details here are asserted in
|
|
8
|
+
* tests rather than assumed:
|
|
9
|
+
*
|
|
10
|
+
* - `status` in an Error is a **string**, not a number.
|
|
11
|
+
* - `ListResponse` uses `Resources` with a capital R.
|
|
12
|
+
* - `startIndex` is **1-based**.
|
|
13
|
+
* - `meta.location` must be an absolute URL; Okta reads it.
|
|
14
|
+
*
|
|
15
|
+
* Pure: no I/O, no port access.
|
|
16
|
+
*/
|
|
17
|
+
import type {
|
|
18
|
+
ScimEnterpriseUser,
|
|
19
|
+
ScimGroupMember,
|
|
20
|
+
ScimGroupRecord,
|
|
21
|
+
ScimGroupWrite,
|
|
22
|
+
ScimMultiValue,
|
|
23
|
+
ScimName,
|
|
24
|
+
ScimUserRecord,
|
|
25
|
+
ScimUserWrite,
|
|
26
|
+
} from "../../types/scim"
|
|
27
|
+
import { err, ok, type Result } from "../../types/result"
|
|
28
|
+
|
|
29
|
+
export const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
|
|
30
|
+
export const SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
|
|
31
|
+
export const SCIM_ENTERPRISE_SCHEMA =
|
|
32
|
+
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
|
|
33
|
+
export const SCIM_LIST_SCHEMA =
|
|
34
|
+
"urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
|
35
|
+
export const SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"
|
|
36
|
+
export const SCIM_PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
|
37
|
+
|
|
38
|
+
/** RFC 7644 §3.12 `scimType` values we emit. */
|
|
39
|
+
export type ScimErrorType =
|
|
40
|
+
| "invalidFilter"
|
|
41
|
+
| "invalidPath"
|
|
42
|
+
| "invalidSyntax"
|
|
43
|
+
| "invalidValue"
|
|
44
|
+
| "mutability"
|
|
45
|
+
| "noTarget"
|
|
46
|
+
| "uniqueness"
|
|
47
|
+
| "tooMany"
|
|
48
|
+
|
|
49
|
+
export type ScimValidationError = {
|
|
50
|
+
status: number
|
|
51
|
+
detail: string
|
|
52
|
+
scimType?: ScimErrorType
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** RFC 7644 §3.12 error envelope. */
|
|
56
|
+
export function scimErrorBody(
|
|
57
|
+
status: number,
|
|
58
|
+
detail: string,
|
|
59
|
+
scimType?: ScimErrorType,
|
|
60
|
+
): Record<string, unknown> {
|
|
61
|
+
return {
|
|
62
|
+
schemas: [SCIM_ERROR_SCHEMA],
|
|
63
|
+
// A string, per the RFC's own examples — a number here is a common
|
|
64
|
+
// implementation bug and Okta's validator notices.
|
|
65
|
+
status: String(status),
|
|
66
|
+
...(scimType !== undefined ? { scimType } : {}),
|
|
67
|
+
detail,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
72
|
+
typeof v === "object" && v !== null && !Array.isArray(v)
|
|
73
|
+
|
|
74
|
+
const asString = (v: unknown): string | undefined =>
|
|
75
|
+
typeof v === "string" && v.length > 0 ? v : undefined
|
|
76
|
+
|
|
77
|
+
function parseName(v: unknown): ScimName | undefined {
|
|
78
|
+
if (!isRecord(v)) return undefined
|
|
79
|
+
const out: ScimName = {}
|
|
80
|
+
for (const k of [
|
|
81
|
+
"formatted",
|
|
82
|
+
"familyName",
|
|
83
|
+
"givenName",
|
|
84
|
+
"middleName",
|
|
85
|
+
"honorificPrefix",
|
|
86
|
+
"honorificSuffix",
|
|
87
|
+
] as const) {
|
|
88
|
+
const s = asString(v[k])
|
|
89
|
+
if (s !== undefined) out[k] = s
|
|
90
|
+
}
|
|
91
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseMultiValues(v: unknown): ScimMultiValue[] | undefined {
|
|
95
|
+
if (!Array.isArray(v)) return undefined
|
|
96
|
+
const out: ScimMultiValue[] = []
|
|
97
|
+
for (const entry of v) {
|
|
98
|
+
if (!isRecord(entry)) continue
|
|
99
|
+
const value = asString(entry["value"])
|
|
100
|
+
if (value === undefined) continue
|
|
101
|
+
const type = asString(entry["type"])
|
|
102
|
+
const primary = entry["primary"]
|
|
103
|
+
out.push({
|
|
104
|
+
value,
|
|
105
|
+
...(type !== undefined ? { type } : {}),
|
|
106
|
+
...(typeof primary === "boolean" ? { primary } : {}),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
return out.length > 0 ? out : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseEnterprise(v: unknown): ScimEnterpriseUser | undefined {
|
|
113
|
+
if (!isRecord(v)) return undefined
|
|
114
|
+
const out: ScimEnterpriseUser = {}
|
|
115
|
+
for (const k of [
|
|
116
|
+
"employeeNumber",
|
|
117
|
+
"costCenter",
|
|
118
|
+
"organization",
|
|
119
|
+
"division",
|
|
120
|
+
"department",
|
|
121
|
+
] as const) {
|
|
122
|
+
const s = asString(v[k])
|
|
123
|
+
if (s !== undefined) out[k] = s
|
|
124
|
+
}
|
|
125
|
+
const mgr = v["manager"]
|
|
126
|
+
if (isRecord(mgr)) {
|
|
127
|
+
const value = asString(mgr["value"])
|
|
128
|
+
const displayName = asString(mgr["displayName"])
|
|
129
|
+
if (value !== undefined || displayName !== undefined) {
|
|
130
|
+
out.manager = {
|
|
131
|
+
...(value !== undefined ? { value } : {}),
|
|
132
|
+
...(displayName !== undefined ? { displayName } : {}),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validate a `POST` / `PUT` user payload into a `ScimUserWrite`.
|
|
141
|
+
*
|
|
142
|
+
* Unknown attributes are ignored rather than rejected: SCIM clients send
|
|
143
|
+
* plenty the spec allows and we do not model, and failing the whole
|
|
144
|
+
* provisioning run over an extra field would be hostile. The two
|
|
145
|
+
* exceptions are hard errors — a missing `userName`, which leaves the
|
|
146
|
+
* record unidentifiable, and `password`, which we refuse to handle at
|
|
147
|
+
* all rather than accept and drop.
|
|
148
|
+
*/
|
|
149
|
+
export function parseUserWrite(
|
|
150
|
+
body: unknown,
|
|
151
|
+
): Result<ScimUserWrite, ScimValidationError> {
|
|
152
|
+
if (!isRecord(body)) {
|
|
153
|
+
return err({
|
|
154
|
+
status: 400,
|
|
155
|
+
scimType: "invalidSyntax",
|
|
156
|
+
detail: "request body must be a JSON object",
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (body["password"] !== undefined) {
|
|
161
|
+
return err({
|
|
162
|
+
status: 400,
|
|
163
|
+
scimType: "invalidValue",
|
|
164
|
+
detail:
|
|
165
|
+
"this Service Provider does not accept 'password' over SCIM; " +
|
|
166
|
+
"credentials are managed by the authentication methods, not the " +
|
|
167
|
+
"directory feed",
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const userName = asString(body["userName"])
|
|
172
|
+
if (userName === undefined) {
|
|
173
|
+
return err({
|
|
174
|
+
status: 400,
|
|
175
|
+
scimType: "invalidValue",
|
|
176
|
+
detail: "userName is required and must be a non-empty string",
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const activeRaw = body["active"]
|
|
181
|
+
if (activeRaw !== undefined && typeof activeRaw !== "boolean") {
|
|
182
|
+
return err({
|
|
183
|
+
status: 400,
|
|
184
|
+
scimType: "invalidValue",
|
|
185
|
+
detail: "active must be a boolean",
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const externalId = asString(body["externalId"])
|
|
190
|
+
const displayName = asString(body["displayName"])
|
|
191
|
+
const name = parseName(body["name"])
|
|
192
|
+
const emails = parseMultiValues(body["emails"])
|
|
193
|
+
const phoneNumbers = parseMultiValues(body["phoneNumbers"])
|
|
194
|
+
const enterprise = parseEnterprise(body[SCIM_ENTERPRISE_SCHEMA])
|
|
195
|
+
|
|
196
|
+
return ok({
|
|
197
|
+
userName,
|
|
198
|
+
// SCIM defaults `active` to true on create. Resolving the default
|
|
199
|
+
// here keeps the semantics in one place instead of in every host.
|
|
200
|
+
active: activeRaw === undefined ? true : activeRaw,
|
|
201
|
+
...(externalId !== undefined ? { externalId } : {}),
|
|
202
|
+
...(displayName !== undefined ? { displayName } : {}),
|
|
203
|
+
...(name !== undefined ? { name } : {}),
|
|
204
|
+
...(emails !== undefined ? { emails } : {}),
|
|
205
|
+
...(phoneNumbers !== undefined ? { phoneNumbers } : {}),
|
|
206
|
+
...(enterprise !== undefined ? { enterprise } : {}),
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const iso = (ms: number | undefined): string | undefined =>
|
|
211
|
+
ms === undefined ? undefined : new Date(ms).toISOString()
|
|
212
|
+
|
|
213
|
+
/** Serialize a host record into a SCIM User resource. */
|
|
214
|
+
export function serializeUser(
|
|
215
|
+
user: ScimUserRecord,
|
|
216
|
+
baseUrl: string,
|
|
217
|
+
): Record<string, unknown> {
|
|
218
|
+
const schemas = [SCIM_USER_SCHEMA]
|
|
219
|
+
if (user.enterprise !== undefined) schemas.push(SCIM_ENTERPRISE_SCHEMA)
|
|
220
|
+
|
|
221
|
+
const created = iso(user.createdAt)
|
|
222
|
+
const lastModified = iso(user.updatedAt)
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
schemas,
|
|
226
|
+
id: user.id,
|
|
227
|
+
...(user.externalId !== undefined ? { externalId: user.externalId } : {}),
|
|
228
|
+
userName: user.userName,
|
|
229
|
+
...(user.name !== undefined ? { name: user.name } : {}),
|
|
230
|
+
...(user.displayName !== undefined
|
|
231
|
+
? { displayName: user.displayName }
|
|
232
|
+
: {}),
|
|
233
|
+
...(user.emails !== undefined ? { emails: user.emails } : {}),
|
|
234
|
+
...(user.phoneNumbers !== undefined
|
|
235
|
+
? { phoneNumbers: user.phoneNumbers }
|
|
236
|
+
: {}),
|
|
237
|
+
active: user.active,
|
|
238
|
+
...(user.enterprise !== undefined
|
|
239
|
+
? { [SCIM_ENTERPRISE_SCHEMA]: user.enterprise }
|
|
240
|
+
: {}),
|
|
241
|
+
meta: {
|
|
242
|
+
resourceType: "User",
|
|
243
|
+
...(created !== undefined ? { created } : {}),
|
|
244
|
+
...(lastModified !== undefined ? { lastModified } : {}),
|
|
245
|
+
location: `${baseUrl}/Users/${encodeURIComponent(user.id)}`,
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** RFC 7644 §3.4.2 ListResponse. */
|
|
251
|
+
export function serializeList(
|
|
252
|
+
users: ScimUserRecord[],
|
|
253
|
+
totalResults: number,
|
|
254
|
+
startIndex: number,
|
|
255
|
+
baseUrl: string,
|
|
256
|
+
): Record<string, unknown> {
|
|
257
|
+
return {
|
|
258
|
+
schemas: [SCIM_LIST_SCHEMA],
|
|
259
|
+
totalResults,
|
|
260
|
+
startIndex,
|
|
261
|
+
itemsPerPage: users.length,
|
|
262
|
+
// Capital R — the spec's own casing, and a frequent interop bug.
|
|
263
|
+
Resources: users.map((u) => serializeUser(u, baseUrl)),
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function parseMembers(v: unknown): ScimGroupMember[] | undefined {
|
|
268
|
+
// Some IdPs send a bare object where the spec wants an array.
|
|
269
|
+
const list = Array.isArray(v) ? v : isRecord(v) ? [v] : undefined
|
|
270
|
+
if (list === undefined) return undefined
|
|
271
|
+
const out: ScimGroupMember[] = []
|
|
272
|
+
for (const entry of list) {
|
|
273
|
+
if (!isRecord(entry)) continue
|
|
274
|
+
const value = asString(entry["value"])
|
|
275
|
+
if (value === undefined) continue
|
|
276
|
+
const display = asString(entry["display"])
|
|
277
|
+
out.push({ value, ...(display !== undefined ? { display } : {}) })
|
|
278
|
+
}
|
|
279
|
+
return out
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export { parseMembers }
|
|
283
|
+
|
|
284
|
+
/** Validate a `POST` / `PUT` group payload. */
|
|
285
|
+
export function parseGroupWrite(
|
|
286
|
+
body: unknown,
|
|
287
|
+
): Result<ScimGroupWrite, ScimValidationError> {
|
|
288
|
+
if (!isRecord(body)) {
|
|
289
|
+
return err({
|
|
290
|
+
status: 400,
|
|
291
|
+
scimType: "invalidSyntax",
|
|
292
|
+
detail: "request body must be a JSON object",
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
const displayName = asString(body["displayName"])
|
|
296
|
+
if (displayName === undefined) {
|
|
297
|
+
return err({
|
|
298
|
+
status: 400,
|
|
299
|
+
scimType: "invalidValue",
|
|
300
|
+
detail: "displayName is required and must be a non-empty string",
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
const externalId = asString(body["externalId"])
|
|
304
|
+
const members = parseMembers(body["members"])
|
|
305
|
+
return ok({
|
|
306
|
+
displayName,
|
|
307
|
+
...(externalId !== undefined ? { externalId } : {}),
|
|
308
|
+
...(members !== undefined ? { members } : {}),
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Serialize a host group record into a SCIM Group resource. */
|
|
313
|
+
export function serializeGroup(
|
|
314
|
+
group: ScimGroupRecord,
|
|
315
|
+
baseUrl: string,
|
|
316
|
+
): Record<string, unknown> {
|
|
317
|
+
const created = iso(group.createdAt)
|
|
318
|
+
const lastModified = iso(group.updatedAt)
|
|
319
|
+
return {
|
|
320
|
+
schemas: [SCIM_GROUP_SCHEMA],
|
|
321
|
+
id: group.id,
|
|
322
|
+
...(group.externalId !== undefined ? { externalId: group.externalId } : {}),
|
|
323
|
+
displayName: group.displayName,
|
|
324
|
+
// Omitted entirely when the host did not load membership — `[]`
|
|
325
|
+
// would tell the client the group has been emptied.
|
|
326
|
+
...(group.members !== undefined
|
|
327
|
+
? {
|
|
328
|
+
members: group.members.map((m) => ({
|
|
329
|
+
value: m.value,
|
|
330
|
+
...(m.display !== undefined ? { display: m.display } : {}),
|
|
331
|
+
$ref: `${baseUrl}/Users/${encodeURIComponent(m.value)}`,
|
|
332
|
+
type: "User",
|
|
333
|
+
})),
|
|
334
|
+
}
|
|
335
|
+
: {}),
|
|
336
|
+
meta: {
|
|
337
|
+
resourceType: "Group",
|
|
338
|
+
...(created !== undefined ? { created } : {}),
|
|
339
|
+
...(lastModified !== undefined ? { lastModified } : {}),
|
|
340
|
+
location: `${baseUrl}/Groups/${encodeURIComponent(group.id)}`,
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function serializeGroupList(
|
|
346
|
+
groups: ScimGroupRecord[],
|
|
347
|
+
totalResults: number,
|
|
348
|
+
startIndex: number,
|
|
349
|
+
baseUrl: string,
|
|
350
|
+
): Record<string, unknown> {
|
|
351
|
+
return {
|
|
352
|
+
schemas: [SCIM_LIST_SCHEMA],
|
|
353
|
+
totalResults,
|
|
354
|
+
startIndex,
|
|
355
|
+
itemsPerPage: groups.length,
|
|
356
|
+
Resources: groups.map((g) => serializeGroup(g, baseUrl)),
|
|
357
|
+
}
|
|
358
|
+
}
|
package/src/http/context.ts
CHANGED
|
@@ -11,12 +11,15 @@ import type { AuditLog } from "../ports/audit-log"
|
|
|
11
11
|
import type { ConfigStore } from "../ports/config-store"
|
|
12
12
|
import type { KeyStore } from "../ports/key-store"
|
|
13
13
|
import type { MethodStore } from "../ports/method-store"
|
|
14
|
+
import type { ScimDirectory } from "../ports/scim-directory"
|
|
14
15
|
import type { SessionStore } from "../ports/session-store"
|
|
15
16
|
import type { TokenStore } from "../ports/token-store"
|
|
16
17
|
import type { MethodCache } from "../domain/method-cache"
|
|
17
18
|
import type { AuthError } from "../types/error"
|
|
18
19
|
import type {
|
|
19
20
|
ExchangeAudience,
|
|
21
|
+
LogoutEventInput,
|
|
22
|
+
LogoutHookResult,
|
|
20
23
|
PersistUpstreamTokens,
|
|
21
24
|
RegisterClient,
|
|
22
25
|
RenderPicker,
|
|
@@ -41,6 +44,8 @@ export type HttpDeps = {
|
|
|
41
44
|
keyStore: KeyStore
|
|
42
45
|
methodStore?: MethodStore
|
|
43
46
|
auditLog?: AuditLog
|
|
47
|
+
/** See `IdPOptions.scimDirectory`. Absent ⇒ `/scim/v2/*` answers 501. */
|
|
48
|
+
scimDirectory?: ScimDirectory
|
|
44
49
|
methodCache: MethodCache
|
|
45
50
|
stateKeys: StateKeyRing
|
|
46
51
|
/** Resolves the issuer URL — string or per-request function. */
|
|
@@ -49,6 +54,10 @@ export type HttpDeps = {
|
|
|
49
54
|
callbackHostFor?: (tenantId: TenantId) => string
|
|
50
55
|
resolveTenant: (req: Request) => Promise<Result<TenantId, AuthError>>
|
|
51
56
|
success: (input: SuccessMapInput) => Promise<SubjectClaim>
|
|
57
|
+
/** See `IdPOptions.onLogout` — upstream Single Logout host hook. */
|
|
58
|
+
onLogout?: (
|
|
59
|
+
input: LogoutEventInput,
|
|
60
|
+
) => Promise<LogoutHookResult> | LogoutHookResult
|
|
52
61
|
persistUpstreamTokens?: PersistUpstreamTokens
|
|
53
62
|
exchangeAudience?: ExchangeAudience
|
|
54
63
|
renderPicker?: RenderPicker
|
package/src/http/cookies.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* strip method-returned `Set-Cookie` / security / `Cache-Control` headers so
|
|
8
8
|
* methods cannot bypass policy.
|
|
9
9
|
*/
|
|
10
|
-
import type { SetCookie } from "../types/method"
|
|
10
|
+
import type { CachePolicy, SetCookie } from "../types/method"
|
|
11
11
|
|
|
12
12
|
/** Parse an incoming `Cookie:` header into a read-only map. */
|
|
13
13
|
export function parseCookieHeader(header: string | null): Map<string, string> {
|
|
@@ -117,6 +117,24 @@ const STRIPPED_HEADERS = [
|
|
|
117
117
|
"cache-control",
|
|
118
118
|
] as const
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Serialize a `MethodResult.challenge`'s `CachePolicy` into a
|
|
122
|
+
* `Cache-Control` header value. The framework owns this header (methods
|
|
123
|
+
* cannot set it directly — see `STRIPPED_HEADERS`); a method opts into
|
|
124
|
+
* caching via the typed `cache` field and this renders it. Absent /
|
|
125
|
+
* `maxAge: 0` ⇒ `no-store` (the safe default for auth UI).
|
|
126
|
+
*/
|
|
127
|
+
export function cacheControlHeader(cache: CachePolicy | undefined): string {
|
|
128
|
+
if (!cache) return "no-store"
|
|
129
|
+
if ((cache.maxAge ?? -1) === 0) return "no-store"
|
|
130
|
+
const parts: string[] = []
|
|
131
|
+
if (cache.isPrivate) parts.push("private")
|
|
132
|
+
if (cache.maxAge !== undefined) parts.push(`max-age=${cache.maxAge}`)
|
|
133
|
+
if (cache.sMaxAge !== undefined) parts.push(`s-maxage=${cache.sMaxAge}`)
|
|
134
|
+
if (cache.immutable) parts.push("immutable")
|
|
135
|
+
return parts.length ? parts.join(", ") : "no-store"
|
|
136
|
+
}
|
|
137
|
+
|
|
120
138
|
export type ApplyOptions = {
|
|
121
139
|
setCookies?: SetCookie[]
|
|
122
140
|
cookieDefaults?: CookieDefaults
|
|
@@ -24,7 +24,7 @@ import type {
|
|
|
24
24
|
import type { PickerContext, PickerMethod } from "../../types/picker"
|
|
25
25
|
import { renderPicker as renderDefaultPicker } from "../../ui/picker"
|
|
26
26
|
|
|
27
|
-
import { applyResponsePolicy } from "../cookies"
|
|
27
|
+
import { applyResponsePolicy, cacheControlHeader } from "../cookies"
|
|
28
28
|
import type { HttpContext, HttpDeps } from "../context"
|
|
29
29
|
import {
|
|
30
30
|
authorizeDirectErrorResponse,
|
|
@@ -209,7 +209,7 @@ export function makeAuthorizeHandler(deps: HttpDeps) {
|
|
|
209
209
|
return applyResponsePolicy(out.response, {
|
|
210
210
|
setCookies: [flowCookie, ...out.setCookies],
|
|
211
211
|
cookieDefaults: deps.cookieDefaults,
|
|
212
|
-
cacheControl:
|
|
212
|
+
cacheControl: cacheControlHeader(out.cache),
|
|
213
213
|
})
|
|
214
214
|
}
|
|
215
215
|
case "issue-code": {
|
|
@@ -340,23 +340,3 @@ function clearFlowCookie() {
|
|
|
340
340
|
maxAge: 0,
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
|
-
|
|
344
|
-
function cacheControlFor(
|
|
345
|
-
cache:
|
|
346
|
-
| {
|
|
347
|
-
maxAge?: number
|
|
348
|
-
sMaxAge?: number
|
|
349
|
-
isPrivate?: boolean
|
|
350
|
-
immutable?: boolean
|
|
351
|
-
}
|
|
352
|
-
| undefined,
|
|
353
|
-
): string {
|
|
354
|
-
if (!cache) return "no-store"
|
|
355
|
-
if ((cache.maxAge ?? -1) === 0) return "no-store"
|
|
356
|
-
const parts: string[] = []
|
|
357
|
-
if (cache.isPrivate) parts.push("private")
|
|
358
|
-
if (cache.maxAge !== undefined) parts.push(`max-age=${cache.maxAge}`)
|
|
359
|
-
if (cache.sMaxAge !== undefined) parts.push(`s-maxage=${cache.sMaxAge}`)
|
|
360
|
-
if (cache.immutable) parts.push("immutable")
|
|
361
|
-
return parts.length ? parts.join(", ") : "no-store"
|
|
362
|
-
}
|
|
@@ -16,8 +16,16 @@ import { authorizeDirectErrorResponse } from "../errors"
|
|
|
16
16
|
|
|
17
17
|
export function makeCallbackHandler(deps: HttpDeps) {
|
|
18
18
|
return async (c: HttpContext): Promise<Response> => {
|
|
19
|
+
const tenant = c.get("tenant")
|
|
19
20
|
const result = await handleCallback(
|
|
20
|
-
{
|
|
21
|
+
{
|
|
22
|
+
rawRequest: c.req.raw,
|
|
23
|
+
cookies: c.get("cookies"),
|
|
24
|
+
// Only the IdP-initiated (no-state) path reads these; the
|
|
25
|
+
// SP-initiated path derives tenant from the consumed flow.
|
|
26
|
+
...(tenant ? { tenant } : {}),
|
|
27
|
+
issuerUrl: c.get("issuerUrl"),
|
|
28
|
+
},
|
|
21
29
|
{
|
|
22
30
|
configStore: deps.configStore,
|
|
23
31
|
sessionStore: deps.sessionStore,
|
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
* `domain/method-route.handleMethodRoute` orchestrator handles flow lookup
|
|
8
8
|
* + dispatch + result translation.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
handleMethodRoute,
|
|
12
|
+
handlePublicMethodRoute,
|
|
13
|
+
} from "../../domain/method-route"
|
|
14
|
+
import type { RouteKey } from "../../domain/method-dispatch"
|
|
11
15
|
import { authError } from "../../types/error"
|
|
12
16
|
import { isErr } from "../../types/result"
|
|
13
17
|
|
|
14
|
-
import { applyResponsePolicy } from "../cookies"
|
|
18
|
+
import { applyResponsePolicy, cacheControlHeader } from "../cookies"
|
|
15
19
|
import type { HttpContext, HttpDeps } from "../context"
|
|
16
20
|
import { authorizeDirectErrorResponse } from "../errors"
|
|
17
21
|
|
|
@@ -23,6 +27,72 @@ export function makeMethodRouteHandler(deps: HttpDeps) {
|
|
|
23
27
|
authError.invalidRequest("tenant unresolved"),
|
|
24
28
|
)
|
|
25
29
|
}
|
|
30
|
+
|
|
31
|
+
// Public fast-path (additive — leaves the cookie-gated path below
|
|
32
|
+
// byte-identical for every non-public request). Only a clean hit —
|
|
33
|
+
// well-formed `/m/<id>/<sub>`, method resolves, and the resolved
|
|
34
|
+
// method explicitly lists this route key in `publicRoutes` — is
|
|
35
|
+
// diverted here, cookie-free. Anything else falls through to the
|
|
36
|
+
// original flow-cookie-gated logic with its original error
|
|
37
|
+
// ordering. `methodCache.resolve` is a cache lookup; the duplicate
|
|
38
|
+
// resolve on the gated path is a cache hit.
|
|
39
|
+
{
|
|
40
|
+
const u = new URL(c.req.url)
|
|
41
|
+
const segs = u.pathname.split("/").filter(Boolean)
|
|
42
|
+
if (segs.length >= 3 && segs[0] === "m") {
|
|
43
|
+
const methodId = segs[1]!
|
|
44
|
+
const subPath = "/" + segs.slice(2).join("/")
|
|
45
|
+
const httpMethod =
|
|
46
|
+
c.req.method.toUpperCase() === "POST" ? "POST" : "GET"
|
|
47
|
+
const routeKey = `${httpMethod} ${subPath}` as RouteKey
|
|
48
|
+
const resolved = await deps.methodCache.resolve(tenant.config, methodId)
|
|
49
|
+
if (
|
|
50
|
+
!isErr(resolved) &&
|
|
51
|
+
resolved.value.publicRoutes?.includes(routeKey)
|
|
52
|
+
) {
|
|
53
|
+
const issuerUrl = c.get("issuerUrl")
|
|
54
|
+
const callbackHost =
|
|
55
|
+
deps.callbackHostFor?.(tenant.id) ?? new URL(issuerUrl).host
|
|
56
|
+
// Mirrors the ACS URL derivation in domain/authorize.ts
|
|
57
|
+
// (callbackHost/path/url). Source of truth is there; the
|
|
58
|
+
// metadata.test.ts anti-drift test asserts the emitted
|
|
59
|
+
// entityID/ACS equal what buildAuthnRequestRedirect derives.
|
|
60
|
+
const callbackUrl = `${new URL(issuerUrl).protocol}//${callbackHost}/cb/${methodId}`
|
|
61
|
+
const pub = await handlePublicMethodRoute(
|
|
62
|
+
{
|
|
63
|
+
rawRequest: c.req.raw,
|
|
64
|
+
tenant,
|
|
65
|
+
method: resolved.value,
|
|
66
|
+
route: routeKey,
|
|
67
|
+
subPath,
|
|
68
|
+
dispatch: { state: "", callbackUrl, issuerUrl },
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
sessionStore: deps.sessionStore,
|
|
72
|
+
tokenStore: deps.tokenStore,
|
|
73
|
+
clock: deps.clock,
|
|
74
|
+
...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
|
|
75
|
+
...(deps.onLogout ? { onLogout: deps.onLogout } : {}),
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
if (isErr(pub)) return authorizeDirectErrorResponse(pub.error)
|
|
79
|
+
if (pub.value.kind === "denied") {
|
|
80
|
+
return applyResponsePolicy(
|
|
81
|
+
new Response(`access_denied: ${pub.value.reason}`, {
|
|
82
|
+
status: 403,
|
|
83
|
+
headers: { "content-type": "text/plain" },
|
|
84
|
+
}),
|
|
85
|
+
{ cookieDefaults: deps.cookieDefaults },
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
return applyResponsePolicy(pub.value.response, {
|
|
89
|
+
cacheControl: cacheControlHeader(pub.value.cache),
|
|
90
|
+
cookieDefaults: deps.cookieDefaults,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
26
96
|
const cookies = c.get("cookies")
|
|
27
97
|
const flowId = cookies.get("idp.flow")
|
|
28
98
|
if (!flowId) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/scim/v2/*` — SCIM 2.0 provisioning.
|
|
3
|
+
*
|
|
4
|
+
* Thin by design: parse the request into plain data, hand it to
|
|
5
|
+
* `handleScimRequest`, serialize the result. Every decision — auth,
|
|
6
|
+
* routing, validation, status codes — lives in the domain, so the whole
|
|
7
|
+
* SCIM surface is testable without Hono.
|
|
8
|
+
*
|
|
9
|
+
* Responses carry `application/scim+json` per RFC 7644 §3.1.
|
|
10
|
+
*/
|
|
11
|
+
import { handleScimRequest } from "../../domain/scim/handle"
|
|
12
|
+
import { scimErrorBody } from "../../domain/scim/resource"
|
|
13
|
+
|
|
14
|
+
import type { HttpContext, HttpDeps } from "../context"
|
|
15
|
+
|
|
16
|
+
const SCIM_CONTENT_TYPE = "application/scim+json;charset=utf-8"
|
|
17
|
+
const MOUNT = "/scim/v2"
|
|
18
|
+
|
|
19
|
+
function scimResponse(
|
|
20
|
+
status: number,
|
|
21
|
+
body: Record<string, unknown> | null,
|
|
22
|
+
extra: Record<string, string> = {},
|
|
23
|
+
): Response {
|
|
24
|
+
if (body === null || status === 204) {
|
|
25
|
+
return new Response(null, { status, headers: extra })
|
|
26
|
+
}
|
|
27
|
+
return new Response(JSON.stringify(body), {
|
|
28
|
+
status,
|
|
29
|
+
headers: {
|
|
30
|
+
"content-type": SCIM_CONTENT_TYPE,
|
|
31
|
+
// Provisioning responses are per-tenant and mutable; never cached.
|
|
32
|
+
"cache-control": "no-store",
|
|
33
|
+
...extra,
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function makeScimHandler(deps: HttpDeps) {
|
|
39
|
+
return async (c: HttpContext): Promise<Response> => {
|
|
40
|
+
if (!deps.scimDirectory) {
|
|
41
|
+
return scimResponse(
|
|
42
|
+
501,
|
|
43
|
+
scimErrorBody(
|
|
44
|
+
501,
|
|
45
|
+
"SCIM is not configured on this deployment (no scimDirectory " +
|
|
46
|
+
"was supplied to createIdP)",
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tenant = c.get("tenant")
|
|
52
|
+
if (!tenant) {
|
|
53
|
+
// The tenant middleware could not resolve a partition. Answer the
|
|
54
|
+
// same way an unknown token does, so this endpoint never reveals
|
|
55
|
+
// which tenants exist.
|
|
56
|
+
return scimResponse(
|
|
57
|
+
403,
|
|
58
|
+
scimErrorBody(403, "SCIM provisioning is not enabled for this tenant"),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const url = new URL(c.req.url)
|
|
63
|
+
const path = url.pathname.startsWith(MOUNT)
|
|
64
|
+
? url.pathname.slice(MOUNT.length)
|
|
65
|
+
: url.pathname
|
|
66
|
+
|
|
67
|
+
const method = c.req.method.toUpperCase()
|
|
68
|
+
|
|
69
|
+
// Only read a body for methods that carry one. An unparseable body
|
|
70
|
+
// becomes `null` and the domain reports it as invalid syntax with
|
|
71
|
+
// the right SCIM envelope, rather than throwing here.
|
|
72
|
+
let body: unknown = null
|
|
73
|
+
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
|
74
|
+
const raw = await c.req.raw.text()
|
|
75
|
+
if (raw.length > 0) {
|
|
76
|
+
try {
|
|
77
|
+
body = JSON.parse(raw)
|
|
78
|
+
} catch {
|
|
79
|
+
return scimResponse(
|
|
80
|
+
400,
|
|
81
|
+
scimErrorBody(
|
|
82
|
+
400,
|
|
83
|
+
"request body is not valid JSON",
|
|
84
|
+
"invalidSyntax",
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const issuer = c.get("issuerUrl")
|
|
92
|
+
const base = issuer.endsWith("/") ? issuer.slice(0, -1) : issuer
|
|
93
|
+
|
|
94
|
+
const result = await handleScimRequest({
|
|
95
|
+
tenant,
|
|
96
|
+
method,
|
|
97
|
+
path,
|
|
98
|
+
query: url.searchParams,
|
|
99
|
+
body,
|
|
100
|
+
authorization: c.req.header("authorization") ?? null,
|
|
101
|
+
baseUrl: `${base}${MOUNT}`,
|
|
102
|
+
directory: deps.scimDirectory,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
return scimResponse(result.status, result.body, result.headers ?? {})
|
|
106
|
+
}
|
|
107
|
+
}
|