@exvio/os-backend-core 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ import { AuthError } from './errors.ts'
2
+ import type {
3
+ LoginMode,
4
+ LoginPhase,
5
+ LoginPolicy,
6
+ LoginPolicyCheck,
7
+ LoginSurface,
8
+ } from './types.ts'
9
+
10
+ const LOGIN_MODES = new Set<LoginMode>(['hub_managed', 'standalone', 'hybrid'])
11
+ const LOGIN_SURFACES = new Set<LoginSurface>(['email', 'google', 'microsoft', 'passkey', 'hub_ott'])
12
+ const LOGIN_PHASES = new Set<LoginPhase>(['start', 'callback', 'finish'])
13
+
14
+ const MODE_DEFAULTS = Object.freeze({
15
+ hub_managed: Object.freeze(['hub_ott'] as const),
16
+ standalone: Object.freeze(['email', 'google', 'microsoft', 'passkey'] as const),
17
+ hybrid: Object.freeze(['email', 'google', 'microsoft', 'passkey', 'hub_ott'] as const),
18
+ }) satisfies Readonly<Record<LoginMode, readonly LoginSurface[]>>
19
+
20
+ export interface CreateLoginPolicyOptions {
21
+ readonly mode: LoginMode
22
+ /** May only narrow a mode's defaults. Omitted surfaces use the mode default. */
23
+ readonly enabled?: Readonly<Partial<Record<LoginSurface, boolean>>>
24
+ }
25
+
26
+ function invalidInput(): never {
27
+ throw new AuthError({ code: 'invalid_input', status: 400 })
28
+ }
29
+
30
+ function snapshotEnabled(value: unknown): Readonly<Partial<Record<LoginSurface, boolean>>> {
31
+ if (value === undefined) return Object.freeze({})
32
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) invalidInput()
33
+
34
+ let prototype: object | null
35
+ let descriptors: Record<string, PropertyDescriptor>
36
+ try {
37
+ prototype = Object.getPrototypeOf(value)
38
+ descriptors = Object.getOwnPropertyDescriptors(value)
39
+ } catch {
40
+ return invalidInput()
41
+ }
42
+ if (prototype !== Object.prototype && prototype !== null) invalidInput()
43
+
44
+ const snapshot: Partial<Record<LoginSurface, boolean>> = {}
45
+ for (const [key, descriptor] of Object.entries(descriptors)) {
46
+ if (!LOGIN_SURFACES.has(key as LoginSurface) || !('value' in descriptor)) invalidInput()
47
+ if (typeof descriptor.value !== 'boolean') invalidInput()
48
+ snapshot[key as LoginSurface] = descriptor.value
49
+ }
50
+ return Object.freeze(snapshot)
51
+ }
52
+
53
+ export function createLoginPolicy(options: CreateLoginPolicyOptions): LoginPolicy {
54
+ if (!options || typeof options !== 'object') invalidInput()
55
+
56
+ let mode: LoginMode
57
+ let configuredSurfaces: Readonly<Partial<Record<LoginSurface, boolean>>>
58
+ try {
59
+ mode = options.mode
60
+ configuredSurfaces = snapshotEnabled(options.enabled)
61
+ } catch (error) {
62
+ if (error instanceof AuthError) throw error
63
+ return invalidInput()
64
+ }
65
+ if (!LOGIN_MODES.has(mode)) invalidInput()
66
+
67
+ const defaults = new Set<LoginSurface>(MODE_DEFAULTS[mode])
68
+ const enabled = new Set<LoginSurface>()
69
+ for (const surface of LOGIN_SURFACES) {
70
+ const configured = configuredSurfaces[surface]
71
+ // Configuration can fail closed by disabling a default. It cannot enable
72
+ // a credential surface forbidden by the selected mode.
73
+ if (defaults.has(surface) && configured !== false) enabled.add(surface)
74
+ }
75
+
76
+ const enabledSurfaces = Object.freeze([...enabled])
77
+ const allows = (check: LoginPolicyCheck): boolean => (
78
+ check !== null
79
+ && typeof check === 'object'
80
+ && LOGIN_SURFACES.has(check.surface)
81
+ && LOGIN_PHASES.has(check.phase)
82
+ && enabled.has(check.surface)
83
+ )
84
+
85
+ return Object.freeze({
86
+ mode,
87
+ enabledSurfaces,
88
+ allows,
89
+ assertAllowed(check: LoginPolicyCheck): void {
90
+ if (!allows(check)) {
91
+ throw new AuthError({ code: 'login_not_allowed', status: 403 })
92
+ }
93
+ },
94
+ })
95
+ }
@@ -0,0 +1,332 @@
1
+ import { AuthError } from './errors.ts'
2
+ import { createSecureAuthToken } from './session.ts'
3
+ import type {
4
+ OAuthStateConsumeInput,
5
+ OAuthStateIssueInput,
6
+ OAuthStateManager,
7
+ OAuthStatePayload,
8
+ OneTimeAuthStore,
9
+ } from './types.ts'
10
+
11
+ const OAUTH_STATE_PATTERN = /^oas_v1_[A-Za-z0-9_-]{43}$/
12
+ const PROVIDER_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/
13
+ const PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/
14
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9._~-]{16,256}$/
15
+ const KEY_PREFIX_PATTERN = /^[A-Za-z0-9:_-]{1,64}$/
16
+ const encoder = new TextEncoder()
17
+
18
+ interface StoredOAuthState extends OAuthStatePayload {
19
+ readonly v: 1
20
+ }
21
+
22
+ export interface CreateOAuthStateManagerOptions {
23
+ readonly store: OneTimeAuthStore
24
+ readonly ttlSeconds?: number
25
+ readonly maxSerializedBytes?: number
26
+ readonly keyPrefix?: string
27
+ /** Millisecond clock, primarily for deterministic contract tests. */
28
+ readonly now?: () => number
29
+ /** Must return 32 bytes or more of unguessable base64url entropy in production. */
30
+ readonly generateState?: () => string
31
+ }
32
+
33
+ function invalidInput(): never {
34
+ throw new AuthError({ code: 'invalid_input', status: 400 })
35
+ }
36
+
37
+ function assertTenantId(tenantId: number): void {
38
+ if (!Number.isSafeInteger(tenantId) || tenantId <= 0) invalidInput()
39
+ }
40
+
41
+ function assertProvider(provider: string): void {
42
+ if (typeof provider !== 'string' || !PROVIDER_PATTERN.test(provider)) invalidInput()
43
+ }
44
+
45
+ function assertRedirectUri(redirectUri: string): void {
46
+ if (typeof redirectUri !== 'string' || redirectUri.length > 2_048) invalidInput()
47
+ let parsed: URL
48
+ try {
49
+ parsed = new URL(redirectUri)
50
+ } catch {
51
+ invalidInput()
52
+ }
53
+ if (
54
+ (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
55
+ || parsed.username !== ''
56
+ || parsed.password !== ''
57
+ || parsed.hash !== ''
58
+ ) invalidInput()
59
+ }
60
+
61
+ function assertVerifier(verifier: string): void {
62
+ if (typeof verifier !== 'string' || !PKCE_VERIFIER_PATTERN.test(verifier)) invalidInput()
63
+ }
64
+
65
+ function assertInitiatingSessionId(sessionId: string | undefined): void {
66
+ if (sessionId !== undefined && (
67
+ typeof sessionId !== 'string' || !SESSION_ID_PATTERN.test(sessionId)
68
+ )) invalidInput()
69
+ }
70
+
71
+ function assertReturnTo(returnTo: string | undefined): void {
72
+ if (returnTo === undefined) return
73
+ if (
74
+ typeof returnTo !== 'string'
75
+ || returnTo.length === 0
76
+ || returnTo.length > 2_048
77
+ || !returnTo.startsWith('/')
78
+ || returnTo.startsWith('//')
79
+ || returnTo.includes('\\')
80
+ || /[\u0000-\u001F\u007F]/u.test(returnTo)
81
+ ) invalidInput()
82
+ }
83
+
84
+ function assertIssueInput(input: OAuthStateIssueInput): void {
85
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) invalidInput()
86
+ assertTenantId(input.tenantId)
87
+ assertProvider(input.provider)
88
+ assertRedirectUri(input.redirectUri)
89
+ assertVerifier(input.verifier)
90
+ assertInitiatingSessionId(input.initiatingSessionId)
91
+ assertReturnTo(input.returnTo)
92
+ }
93
+
94
+ function assertConsumeInput(input: OAuthStateConsumeInput): void {
95
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
96
+ throw new AuthError({ code: 'invalid_oauth_state', status: 400 })
97
+ }
98
+ assertTenantId(input.tenantId)
99
+ assertProvider(input.provider)
100
+ assertRedirectUri(input.redirectUri)
101
+ assertInitiatingSessionId(input.initiatingSessionId)
102
+ if (typeof input.state !== 'string' || !OAUTH_STATE_PATTERN.test(input.state)) {
103
+ throw new AuthError({ code: 'invalid_oauth_state', status: 400 })
104
+ }
105
+ }
106
+
107
+ function base64Url(bytes: Uint8Array): string {
108
+ let binary = ''
109
+ for (const byte of bytes) binary += String.fromCharCode(byte)
110
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '')
111
+ }
112
+
113
+ async function hashState(state: string): Promise<string> {
114
+ const digest = await crypto.subtle.digest('SHA-256', encoder.encode(state))
115
+ return base64Url(new Uint8Array(digest))
116
+ }
117
+
118
+ function parseStoredPayload(serialized: string, maxSerializedBytes: number): StoredOAuthState {
119
+ if (encoder.encode(serialized).byteLength > maxSerializedBytes) {
120
+ throw new AuthError({ code: 'invalid_oauth_state', status: 400 })
121
+ }
122
+
123
+ let parsed: unknown
124
+ try {
125
+ parsed = JSON.parse(serialized)
126
+ } catch {
127
+ throw new AuthError({ code: 'invalid_oauth_state', status: 400 })
128
+ }
129
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
130
+ throw new AuthError({ code: 'invalid_oauth_state', status: 400 })
131
+ }
132
+
133
+ const value = parsed as Record<string, unknown>
134
+ const allowedKeys = new Set([
135
+ 'v',
136
+ 'tenantId',
137
+ 'provider',
138
+ 'redirectUri',
139
+ 'verifier',
140
+ 'initiatingSessionId',
141
+ 'returnTo',
142
+ 'issuedAt',
143
+ 'expiresAt',
144
+ ])
145
+ if (Object.keys(value).some(key => !allowedKeys.has(key))) {
146
+ throw new AuthError({ code: 'invalid_oauth_state', status: 400 })
147
+ }
148
+
149
+ try {
150
+ if (value.v !== 1) invalidInput()
151
+ assertTenantId(value.tenantId as number)
152
+ assertProvider(value.provider as string)
153
+ assertRedirectUri(value.redirectUri as string)
154
+ assertVerifier(value.verifier as string)
155
+ assertInitiatingSessionId(value.initiatingSessionId as string | undefined)
156
+ assertReturnTo(value.returnTo as string | undefined)
157
+ if (
158
+ typeof value.issuedAt !== 'number'
159
+ || !Number.isSafeInteger(value.issuedAt)
160
+ || value.issuedAt < 0
161
+ || typeof value.expiresAt !== 'number'
162
+ || !Number.isSafeInteger(value.expiresAt)
163
+ || value.expiresAt <= value.issuedAt
164
+ || value.expiresAt - value.issuedAt > 3_600_000
165
+ ) invalidInput()
166
+ } catch {
167
+ throw new AuthError({ code: 'invalid_oauth_state', status: 400 })
168
+ }
169
+
170
+ return value as unknown as StoredOAuthState
171
+ }
172
+
173
+ /**
174
+ * Issue and atomically consume a tenant-bound OAuth transaction.
175
+ * The backing key contains only SHA-256(state), so Redis/profiler key logs do
176
+ * not disclose a replayable raw state token.
177
+ */
178
+ export function createOAuthStateManager(
179
+ options: CreateOAuthStateManagerOptions,
180
+ ): OAuthStateManager {
181
+ if (
182
+ !options
183
+ || typeof options !== 'object'
184
+ || !options.store
185
+ || typeof options.store.put !== 'function'
186
+ || typeof options.store.take !== 'function'
187
+ ) {
188
+ invalidInput()
189
+ }
190
+ const ttlSeconds = options.ttlSeconds ?? 600
191
+ const maxSerializedBytes = options.maxSerializedBytes ?? 4_096
192
+ const keyPrefix = options.keyPrefix ?? 'auth:oauth-state:v1:'
193
+ const now = options.now ?? Date.now
194
+ const generateState = options.generateState ?? (() => `oas_v1_${createSecureAuthToken()}`)
195
+
196
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 30 || ttlSeconds > 3_600) invalidInput()
197
+ if (
198
+ !Number.isSafeInteger(maxSerializedBytes)
199
+ || maxSerializedBytes < 512
200
+ || maxSerializedBytes > 16_384
201
+ || !KEY_PREFIX_PATTERN.test(keyPrefix)
202
+ || typeof now !== 'function'
203
+ || typeof generateState !== 'function'
204
+ ) invalidInput()
205
+
206
+ // Capture and bind the adapter methods themselves. Reassigning `store.put`
207
+ // or `store.take` after construction cannot downgrade atomic semantics.
208
+ const put = options.store.put.bind(options.store)
209
+ const take = options.store.take.bind(options.store)
210
+
211
+ // Copy every option once. Mutating the caller-owned options object cannot
212
+ // relax policy or change transaction semantics after construction.
213
+ const snapshot = Object.freeze({
214
+ put,
215
+ take,
216
+ ttlSeconds,
217
+ maxSerializedBytes,
218
+ keyPrefix,
219
+ now,
220
+ generateState,
221
+ })
222
+
223
+ const storageKey = async (state: string): Promise<string> => (
224
+ `${snapshot.keyPrefix}${await hashState(state)}`
225
+ )
226
+
227
+ return Object.freeze({
228
+ async issue(input: OAuthStateIssueInput): Promise<string> {
229
+ assertIssueInput(input)
230
+ let state: string
231
+ let issuedAt: number
232
+ try {
233
+ state = snapshot.generateState()
234
+ issuedAt = snapshot.now()
235
+ } catch (error) {
236
+ throw new AuthError({ code: 'invalid_oauth_state', status: 500, cause: error })
237
+ }
238
+ if (typeof state !== 'string' || !OAUTH_STATE_PATTERN.test(state)) {
239
+ throw new AuthError({ code: 'invalid_oauth_state', status: 500 })
240
+ }
241
+ if (!Number.isSafeInteger(issuedAt) || issuedAt < 0) invalidInput()
242
+ const expiresAt = issuedAt + (snapshot.ttlSeconds * 1_000)
243
+ if (!Number.isSafeInteger(expiresAt)) invalidInput()
244
+
245
+ const payload: StoredOAuthState = {
246
+ v: 1,
247
+ tenantId: input.tenantId,
248
+ provider: input.provider,
249
+ redirectUri: input.redirectUri,
250
+ verifier: input.verifier,
251
+ ...(input.initiatingSessionId === undefined
252
+ ? {}
253
+ : { initiatingSessionId: input.initiatingSessionId }),
254
+ ...(input.returnTo === undefined ? {} : { returnTo: input.returnTo }),
255
+ issuedAt,
256
+ expiresAt,
257
+ }
258
+ const serialized = JSON.stringify(payload)
259
+ if (encoder.encode(serialized).byteLength > snapshot.maxSerializedBytes) invalidInput()
260
+
261
+ try {
262
+ await snapshot.put(
263
+ await storageKey(state),
264
+ serialized,
265
+ snapshot.ttlSeconds,
266
+ )
267
+ } catch (error) {
268
+ throw new AuthError({
269
+ code: 'auth_store_unavailable',
270
+ status: 503,
271
+ retryable: true,
272
+ cause: error,
273
+ })
274
+ }
275
+ return state
276
+ },
277
+
278
+ async consume(input: OAuthStateConsumeInput): Promise<OAuthStatePayload> {
279
+ assertConsumeInput(input)
280
+ let serialized: string | null
281
+ try {
282
+ serialized = await snapshot.take(await storageKey(input.state))
283
+ } catch (error) {
284
+ throw new AuthError({
285
+ code: 'auth_store_unavailable',
286
+ status: 503,
287
+ retryable: true,
288
+ cause: error,
289
+ })
290
+ }
291
+ if (serialized === null) {
292
+ throw new AuthError({ code: 'invalid_oauth_state', status: 400 })
293
+ }
294
+
295
+ const payload = parseStoredPayload(serialized, snapshot.maxSerializedBytes)
296
+ let currentTime: number
297
+ try {
298
+ currentTime = snapshot.now()
299
+ } catch (error) {
300
+ throw new AuthError({ code: 'invalid_oauth_state', status: 500, cause: error })
301
+ }
302
+ if (!Number.isSafeInteger(currentTime) || currentTime < 0) invalidInput()
303
+ if (payload.issuedAt > currentTime) {
304
+ throw new AuthError({ code: 'invalid_oauth_state', status: 400 })
305
+ }
306
+ if (payload.expiresAt <= currentTime) {
307
+ throw new AuthError({ code: 'oauth_state_expired', status: 400 })
308
+ }
309
+ if (
310
+ payload.tenantId !== input.tenantId
311
+ || payload.provider !== input.provider
312
+ || payload.redirectUri !== input.redirectUri
313
+ || payload.initiatingSessionId !== input.initiatingSessionId
314
+ ) {
315
+ throw new AuthError({ code: 'oauth_state_mismatch', status: 400 })
316
+ }
317
+
318
+ return Object.freeze({
319
+ tenantId: payload.tenantId,
320
+ provider: payload.provider,
321
+ redirectUri: payload.redirectUri,
322
+ verifier: payload.verifier,
323
+ ...(payload.initiatingSessionId === undefined
324
+ ? {}
325
+ : { initiatingSessionId: payload.initiatingSessionId }),
326
+ ...(payload.returnTo === undefined ? {} : { returnTo: payload.returnTo }),
327
+ issuedAt: payload.issuedAt,
328
+ expiresAt: payload.expiresAt,
329
+ })
330
+ },
331
+ })
332
+ }