@ultimat3/auth 29.3.2 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CLAUDE.md +46 -58
  2. package/README.md +162 -9
  3. package/package.json +20 -5
  4. package/src/adapter.ts +88 -2
  5. package/src/api-key-resolver.ts +3 -3
  6. package/src/auth.ts +29 -7
  7. package/src/authorization-server-clients.ts +169 -0
  8. package/src/authorization-server-config.ts +239 -0
  9. package/src/authorization-server-errors.ts +23 -0
  10. package/src/authorization-server-postgres-sql.ts +107 -0
  11. package/src/authorization-server-postgres.ts +222 -0
  12. package/src/authorization-server-register.ts +98 -0
  13. package/src/authorization-server-request.ts +126 -0
  14. package/src/authorization-server-routes.ts +181 -0
  15. package/src/authorization-server-schema.ts +82 -0
  16. package/src/authorization-server-store.ts +189 -0
  17. package/src/authorization-server-tokens.ts +118 -0
  18. package/src/authorization-server-types.ts +108 -0
  19. package/src/authorization-server-wire.ts +135 -0
  20. package/src/authorization-server.ts +178 -0
  21. package/src/base64url.ts +40 -0
  22. package/src/builtin-adapter-violation.ts +12 -9
  23. package/src/builtin-adapter.ts +31 -5
  24. package/src/cbor-encode.ts +52 -0
  25. package/src/cbor.ts +129 -0
  26. package/src/client-scope.ts +2 -5
  27. package/src/client.ts +16 -0
  28. package/src/cose-key.ts +129 -0
  29. package/src/errors.ts +51 -0
  30. package/src/grant-authorization-code.ts +113 -0
  31. package/src/grant-client-credentials.ts +55 -0
  32. package/src/grant-device.ts +188 -0
  33. package/src/index.ts +108 -3
  34. package/src/jwks.ts +81 -12
  35. package/src/jwt-bearer-resolver.ts +80 -0
  36. package/src/limiter-install.ts +31 -2
  37. package/src/memory-adapter.ts +18 -0
  38. package/src/mfa-challenge.ts +13 -7
  39. package/src/mfa-replay-postgres.ts +71 -0
  40. package/src/mfa.ts +24 -13
  41. package/src/oauth-bearer-resolver.ts +44 -0
  42. package/src/oauth-cookie.ts +2 -1
  43. package/src/oauth-discovery.ts +17 -6
  44. package/src/oauth-exchange.ts +23 -12
  45. package/src/oauth-profile.ts +20 -9
  46. package/src/oauth-route.ts +5 -5
  47. package/src/passkey-assertion.ts +60 -0
  48. package/src/passkey-challenge.ts +59 -0
  49. package/src/passkey-client.ts +156 -0
  50. package/src/passkey-config.ts +92 -0
  51. package/src/passkey-errors.ts +89 -0
  52. package/src/passkey-evidence.ts +74 -0
  53. package/src/passkey-login.ts +127 -0
  54. package/src/passkey-memory.ts +77 -0
  55. package/src/passkey-postgres.ts +155 -0
  56. package/src/passkey-step-up.ts +135 -0
  57. package/src/passkey-verify.ts +180 -0
  58. package/src/passkey-wire.ts +61 -0
  59. package/src/soft-authenticator.ts +208 -0
  60. package/src/subject-target.ts +73 -0
  61. package/src/tables.ts +46 -0
  62. package/src/token-endpoints.ts +61 -0
  63. package/src/tokens.ts +2 -25
  64. package/src/verification-purposes.ts +61 -0
  65. package/src/verify.ts +86 -12
  66. package/src/webauthn-data.ts +111 -0
  67. package/src/workload.ts +67 -15
@@ -0,0 +1,129 @@
1
+ // Single responsibility: a COSE_Key (RFC 9053) a passkey registered, as a WebCrypto key that can
2
+ // check its signatures — ES256 (P-256, the algorithm every platform authenticator uses) and RS256
3
+ // (Windows Hello's). The algorithm comes from the KEY, never from the assertion: a signature cannot
4
+ // choose how it is checked. Every malformed input answers `null`; the caller owns the refusal.
5
+
6
+ import { base64Url } from './base64url';
7
+ import { type CborValue, decodeCbor } from './cbor';
8
+
9
+ /** COSE algorithm identifiers this package verifies. Offered in this order at registration. */
10
+ export const COSE_ES256 = -7;
11
+ export const COSE_RS256 = -257;
12
+ export const PASSKEY_ALGORITHMS: readonly number[] = Object.freeze([COSE_ES256, COSE_RS256]);
13
+
14
+ const KTY_EC2 = 2;
15
+ const KTY_RSA = 3;
16
+ const CRV_P256 = 1;
17
+ /** RSA keys below this are not a credential anybody should accept in 2026. */
18
+ const MIN_RSA_MODULUS_BYTES = 256;
19
+
20
+ export interface PasskeyPublicKey {
21
+ readonly algorithm: number;
22
+ readonly key: CryptoKey;
23
+ }
24
+
25
+ const bytesAt = (
26
+ map: ReadonlyMap<number | string, CborValue>,
27
+ label: number,
28
+ ): Uint8Array | null => {
29
+ const value = map.get(label);
30
+ return value instanceof Uint8Array ? value : null;
31
+ };
32
+
33
+ /** The stored COSE bytes as a verifying key, or `null` when they are not one this package checks. */
34
+ export async function importCoseKey(cose: Uint8Array): Promise<PasskeyPublicKey | null> {
35
+ const decoded = decodeCbor(cose);
36
+ if (!(decoded instanceof Map)) return null;
37
+ const kty = decoded.get(1);
38
+ const algorithm = decoded.get(3);
39
+ try {
40
+ if (kty === KTY_EC2 && algorithm === COSE_ES256) {
41
+ const x = bytesAt(decoded, -2);
42
+ const y = bytesAt(decoded, -3);
43
+ if (decoded.get(-1) !== CRV_P256 || x?.length !== 32 || y?.length !== 32) return null;
44
+ const jwk = { kty: 'EC', crv: 'P-256', x: base64Url(x), y: base64Url(y) };
45
+ const key = await crypto.subtle.importKey(
46
+ 'jwk',
47
+ jwk,
48
+ { name: 'ECDSA', namedCurve: 'P-256' },
49
+ false,
50
+ ['verify'],
51
+ );
52
+ return { algorithm, key };
53
+ }
54
+ if (kty === KTY_RSA && algorithm === COSE_RS256) {
55
+ const n = bytesAt(decoded, -1);
56
+ const e = bytesAt(decoded, -2);
57
+ if (n === null || e === null || n.length < MIN_RSA_MODULUS_BYTES || e.length === 0) {
58
+ return null;
59
+ }
60
+ const jwk = { kty: 'RSA', n: base64Url(n), e: base64Url(e) };
61
+ const key = await crypto.subtle.importKey(
62
+ 'jwk',
63
+ jwk,
64
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
65
+ false,
66
+ ['verify'],
67
+ );
68
+ return { algorithm, key };
69
+ }
70
+ } catch {
71
+ // A point off the curve, or a modulus WebCrypto refuses: not a key.
72
+ return null;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** `false` for every failure, the malformed signature included — never a throw on foreign bytes. */
78
+ export async function verifyCoseSignature(
79
+ checker: PasskeyPublicKey,
80
+ signature: Uint8Array,
81
+ signed: Uint8Array,
82
+ ): Promise<boolean> {
83
+ try {
84
+ if (checker.algorithm === COSE_ES256) {
85
+ const raw = ecdsaDerToRaw(signature);
86
+ if (raw === null) return false;
87
+ return await crypto.subtle.verify(
88
+ { name: 'ECDSA', hash: 'SHA-256' },
89
+ checker.key,
90
+ raw,
91
+ new Uint8Array(signed),
92
+ );
93
+ }
94
+ return await crypto.subtle.verify(
95
+ 'RSASSA-PKCS1-v1_5',
96
+ checker.key,
97
+ new Uint8Array(signature),
98
+ new Uint8Array(signed),
99
+ );
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * An authenticator's ES256 signature is ASN.1 DER — `SEQUENCE { INTEGER r, INTEGER s }` — and
107
+ * WebCrypto checks the raw 64-byte `r || s`. Strict DER: minimal lengths, no trailing bytes, each
108
+ * integer positive and at most 32 bytes once its sign byte is dropped.
109
+ */
110
+ export function ecdsaDerToRaw(der: Uint8Array): Uint8Array<ArrayBuffer> | null {
111
+ if (der.length < 8 || der[0] !== 0x30 || der[1] !== der.length - 2) return null;
112
+ const raw = new Uint8Array(64);
113
+ let at = 2;
114
+ for (const offset of [0, 32]) {
115
+ if (der[at] !== 0x02) return null;
116
+ const length = der[at + 1] ?? 0;
117
+ const start = at + 2;
118
+ if (length === 0 || length > 33 || start + length > der.length) return null;
119
+ let integer = der.subarray(start, start + length);
120
+ // Negative, or a leading zero the next byte did not need: not DER.
121
+ if ((integer[0] ?? 0) & 0x80) return null;
122
+ if (integer[0] === 0 && integer.length > 1 && ((integer[1] ?? 0) & 0x80) === 0) return null;
123
+ if (integer[0] === 0 && integer.length > 1) integer = integer.subarray(1);
124
+ if (integer.length > 32) return null;
125
+ raw.set(integer, offset + 32 - integer.length);
126
+ at = start + length;
127
+ }
128
+ return at === der.length ? raw : null;
129
+ }
package/src/errors.ts CHANGED
@@ -30,6 +30,15 @@ export const AUTH_OWNED_ERROR_CODES = [
30
30
  'X_AUTH_LIMITER_NOT_SHARED',
31
31
  'X_AUTH_LIMITER_POLICY_MISMATCH',
32
32
  'X_MFA_SECRET_UNSEALED',
33
+ 'X_AUTH_SIGN_IN_UNCONFIGURED',
34
+ 'X_AUTH_SERVER_INVALID',
35
+ 'X_AUTH_CONSENT_INVALID',
36
+ 'X_AUTH_GRANT_DISABLED',
37
+ 'X_VERIFICATION_PURPOSE_INVALID',
38
+ 'X_VERIFICATION_PURPOSE_UNKNOWN',
39
+ 'X_PASSKEY_INVALID',
40
+ 'X_PASSKEY_CHALLENGE_EXPIRED',
41
+ 'X_PASSKEY_STEP_UP_REQUIRED',
33
42
  ] as const;
34
43
 
35
44
  /**
@@ -76,6 +85,20 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
76
85
  X_AUTH_LIMITER_NOT_SHARED: 'the lockout is declared fleet-wide and the limiter is per-process',
77
86
  X_AUTH_LIMITER_POLICY_MISMATCH: 'the limiter in use enforces other numbers than the app declared',
78
87
  X_MFA_SECRET_UNSEALED: 'a stored totp secret is not sealed, so it is never read as one',
88
+ X_AUTH_SIGN_IN_UNCONFIGURED:
89
+ 'a people-facing role started with no sign-in settings outside development and test',
90
+ X_AUTH_SERVER_INVALID: 'the authorization server was configured with a field it cannot run on',
91
+ X_AUTH_CONSENT_INVALID: 'a consent was approved for a request the authorization server refuses',
92
+ X_AUTH_GRANT_DISABLED: 'a token request named a grant this authorization server does not enable',
93
+ X_VERIFICATION_PURPOSE_INVALID: 'a verification purpose was declared twice or without a template',
94
+ X_VERIFICATION_PURPOSE_UNKNOWN:
95
+ 'a verification token was issued or consumed for a purpose nothing declared',
96
+ X_PASSKEY_INVALID:
97
+ 'a passkey ceremony response failed its signature, origin, rp id, flag or counter check',
98
+ X_PASSKEY_CHALLENGE_EXPIRED:
99
+ 'a passkey ceremony named a challenge that is unknown, already used or past its ttl',
100
+ X_PASSKEY_STEP_UP_REQUIRED:
101
+ 'a sensitive action ran without a fresh user-verified passkey proof for its digest',
79
102
  };
80
103
 
81
104
  // Registered unconditionally, in one call: a second package claiming a code auth owns has to fail
@@ -234,6 +257,21 @@ export const mfaSecretUnsealed = (): AuthError =>
234
257
  fix: 'x auth seal-mfa --json',
235
258
  });
236
259
 
260
+ /**
261
+ * The boot guard `x g sign-in` writes, refusing a people-facing role (`web`, `sync`) outside
262
+ * development and test when the app's sign-in settings are unset: started anyway, every
263
+ * policy-protected route answers 401 and nobody can sign in to fix it. The guard is the app's —
264
+ * an API-only app has no sign-in — so the code is here and the decision to throw it is not.
265
+ */
266
+ export const signInUnconfigured = (role: string, missing: readonly string[]): AuthError =>
267
+ new AuthError({
268
+ code: 'X_AUTH_SIGN_IN_UNCONFIGURED',
269
+ cause: `the ${renderCauseValue(role)} role serves people and sign-in is unconfigured: ${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} unset`,
270
+ // The env edit itself: `x env example` is an app-tree command, absent from a deployed image.
271
+ fix: `export <KEY>="<value>" # each key the cause names, in the role's deployed environment (compose environment:, the platform's secrets), then restart the role`,
272
+ meta: { role, missing: [...missing] },
273
+ });
274
+
237
275
  /**
238
276
  * `defineAuth({ mfa: { required: true } })`, refused where it is declared. The option read as a
239
277
  * security guarantee — "this deployment requires a second factor" — and nothing in this package
@@ -254,6 +292,19 @@ export const mfaRequiredUnenforceable = (): AuthError =>
254
292
  fix: 'drop required from defineAuth({ mfa }) and gate it in your own sign-in handler, which is where the enrolment route lives: if (user.mfaSecret === null) send them to enrolTotp(auth, { account: user.email }) instead of createSession(auth.sessions, ...)',
255
293
  });
256
294
 
295
+ /**
296
+ * A `totpReplay` with no `claim`: a guard written for the `isUsed`/`remember` pair, which could not
297
+ * be atomic across a network. `X_CONFIG_INVALID` is core's code, borrowed like the one above.
298
+ */
299
+ export const totpReplayGuardInvalid = (): AuthError =>
300
+ new AuthError({
301
+ code: 'X_CONFIG_INVALID',
302
+ cause:
303
+ 'defineAuth({ totpReplay }) has no claim function, so completeMfa has no way to spend a TOTP step',
304
+ fix: 'defineAuth({ totpReplay: { claim: (subject, step, at) => firstUse(subject, step, at) } }) # claim(subject, step, at) replaces isUsed/remember, answering true the first time and false for a replay; or drop the totpReplay option on a framework-booted process',
305
+ meta: { option: 'totpReplay' },
306
+ });
307
+
257
308
  /**
258
309
  * `login(auth, { handle })` on an app whose `defineAuth` named no `handles` lookup. Refused before
259
310
  * any bucket is reserved or any hash run: this is a wiring fault, never a failed guess.
@@ -0,0 +1,113 @@
1
+ // Single responsibility: the two person-grant legs of `POST /oauth/token` — a code for tokens
2
+ // (RFC 6749 §4.1.3 + PKCE) and a refresh token for new ones (§6, OAuth 2.1 rotation). The rules:
3
+ // a code is single use and its replay revokes what it minted; a retired refresh token's replay
4
+ // revokes the whole grant; the redirect and the resource must be the ones the code was bound to.
5
+
6
+ import type { ResolvedAuthServerConfig } from './authorization-server-config';
7
+ import {
8
+ firstRefresh,
9
+ pkceMatches,
10
+ refreshRecord,
11
+ refused,
12
+ type TokenOutcome,
13
+ tokenAnswer,
14
+ } from './authorization-server-tokens';
15
+ import type { AuthServerClient } from './authorization-server-types';
16
+ import { sha256Hex } from './tokens';
17
+
18
+ type Form = ReadonlyMap<string, string>;
19
+
20
+ const invalidGrant = (description: string): TokenOutcome => refused('invalid_grant', description);
21
+
22
+ export async function exchangeCode(
23
+ config: ResolvedAuthServerConfig,
24
+ form: Form,
25
+ client: AuthServerClient,
26
+ ): Promise<TokenOutcome> {
27
+ const code = form.get('code');
28
+ const verifier = form.get('code_verifier');
29
+ if (code === undefined || verifier === undefined) {
30
+ return refused('invalid_request', 'code and code_verifier are required');
31
+ }
32
+ const now = config.clock.now();
33
+ const taken = await config.store.takeCode(sha256Hex(code), now);
34
+ if (taken === null) return invalidGrant('the code is unknown');
35
+ const record = taken.code;
36
+ if (!taken.fresh) {
37
+ // Somebody holds a copy of a code that was already redeemed: whatever it minted is suspect.
38
+ await config.store.revokeGrant(record.grantId, now);
39
+ config.emit({ kind: 'code-replayed', clientId: record.clientId });
40
+ return invalidGrant('the code was already used; the grant it minted is revoked');
41
+ }
42
+ if (record.clientId !== client.clientId)
43
+ return invalidGrant('the code was issued to another client');
44
+ if (record.expiresAt.getTime() <= now.getTime()) return invalidGrant('the code expired');
45
+ if (form.get('redirect_uri') !== record.redirectUri) {
46
+ return invalidGrant('redirect_uri is not the one the code was issued for');
47
+ }
48
+ if (!pkceMatches(verifier, record.codeChallenge)) {
49
+ return invalidGrant('the code_verifier does not match the code_challenge');
50
+ }
51
+ const resource = form.get('resource');
52
+ if (resource !== undefined && resource !== record.resource) {
53
+ return refused('invalid_target', 'the resource is not the one the grant is bound to');
54
+ }
55
+ const grant = await config.store.getGrant(record.grantId);
56
+ if (grant === null || grant.revokedAt !== null) return invalidGrant('the grant is revoked');
57
+ const refresh = await firstRefresh(config, grant.id);
58
+ return tokenAnswer(config, grant, config.accessSeconds, refresh);
59
+ }
60
+
61
+ export async function exchangeRefresh(
62
+ config: ResolvedAuthServerConfig,
63
+ form: Form,
64
+ client: AuthServerClient,
65
+ ): Promise<TokenOutcome> {
66
+ const presented = form.get('refresh_token');
67
+ if (presented === undefined) return refused('invalid_request', 'refresh_token is required');
68
+ const now = config.clock.now();
69
+ const hash = sha256Hex(presented);
70
+ const token = await config.store.getToken(hash);
71
+ if (token === null || token.kind !== 'refresh')
72
+ return invalidGrant('the refresh token is unknown');
73
+ if (token.retiredAt !== null) return replayed(config, token.grantId, client.clientId);
74
+ const grant = await config.store.getGrant(token.grantId);
75
+ if (grant === null || grant.revokedAt !== null) return invalidGrant('the grant is revoked');
76
+ if (grant.clientId !== client.clientId)
77
+ return invalidGrant('the token was issued to another client');
78
+ if (grant.refreshedAt.getTime() + config.refreshIdleMs <= now.getTime()) {
79
+ return invalidGrant('the grant sat idle past its refresh window');
80
+ }
81
+ const scope = form.get('scope');
82
+ if (
83
+ scope !== undefined &&
84
+ scope.split(' ').sort().join(' ') !== [...grant.scopes].sort().join(' ')
85
+ ) {
86
+ // A narrower refresh would need per-token scopes; the honest answer is to say so.
87
+ return refused(
88
+ 'invalid_scope',
89
+ 'a refresh keeps the granted scope; authorize again to change it',
90
+ );
91
+ }
92
+ const resource = form.get('resource');
93
+ if (resource !== undefined && resource !== grant.resource) {
94
+ return refused('invalid_target', 'the resource is not the one the grant is bound to');
95
+ }
96
+ const next = refreshRecord(config, grant.id);
97
+ const rotation = await config.store.rotateRefresh(hash, next.record, now);
98
+ if (rotation.outcome === 'replayed') return replayed(config, rotation.grantId, client.clientId);
99
+ if (rotation.outcome === 'unknown') return invalidGrant('the refresh token is unknown');
100
+ await config.store.touchGrant(grant.id, now);
101
+ return tokenAnswer(config, grant, config.accessSeconds, next.token);
102
+ }
103
+
104
+ /** A retired refresh token came back: one of two holders is a thief, so neither keeps the grant. */
105
+ async function replayed(
106
+ config: ResolvedAuthServerConfig,
107
+ grantId: string,
108
+ clientId: string,
109
+ ): Promise<TokenOutcome> {
110
+ await config.store.revokeGrant(grantId, config.clock.now());
111
+ config.emit({ kind: 'refresh-replayed', clientId });
112
+ return invalidGrant('the refresh token was already rotated; the grant is revoked');
113
+ }
@@ -0,0 +1,55 @@
1
+ // Single responsibility: RFC 6749 §4.4 — a machine with no person to consent (a partner's nightly
2
+ // sync) trades its own secret for a short-lived access token. Confidential clients only, the
3
+ // subject is the client itself, scopes are its registered set narrowed by the request, and there
4
+ // is never a refresh token: the secret IS the long-lived credential, so a second one is a liability.
5
+
6
+ import type { ResolvedAuthServerConfig } from './authorization-server-config';
7
+ import { screenResource } from './authorization-server-request';
8
+ import { refused, type TokenOutcome, tokenAnswer } from './authorization-server-tokens';
9
+ import type { AuthServerClient } from './authorization-server-types';
10
+ import { randomToken } from './tokens';
11
+
12
+ export async function exchangeClientCredentials(
13
+ config: ResolvedAuthServerConfig,
14
+ form: ReadonlyMap<string, string>,
15
+ client: AuthServerClient,
16
+ ): Promise<TokenOutcome> {
17
+ // The wire already proved the secret; a PUBLIC client has none to prove, so it never gets here.
18
+ if (client.secretHash === null) {
19
+ return refused('unauthorized_client', 'client_credentials needs a confidential client', 401);
20
+ }
21
+ if (!client.grantTypes.includes('client_credentials')) {
22
+ return refused('unauthorized_client', 'this client may not use client_credentials');
23
+ }
24
+ // An empty registration is the server's whole set, as `AuthServerClient.scopes` documents.
25
+ const registered =
26
+ client.scopes.length === 0
27
+ ? [...config.scopes]
28
+ : client.scopes.filter((scope) => config.scopes.has(scope));
29
+ const requested = form.get('scope');
30
+ const scopes =
31
+ requested === undefined || requested.trim() === ''
32
+ ? registered
33
+ : [...new Set(requested.split(' ').filter((scope) => scope !== ''))];
34
+ if (scopes.length === 0 || !scopes.every((scope) => registered.includes(scope))) {
35
+ return refused('invalid_scope', 'a requested scope is not one this client was registered for');
36
+ }
37
+ const resource = screenResource(config, form.get('resource'));
38
+ if (resource === false) {
39
+ return refused('invalid_target', 'the resource is not one this server issues tokens for');
40
+ }
41
+ const now = config.clock.now();
42
+ const grant = {
43
+ id: `g_${randomToken(16)}`,
44
+ clientId: client.clientId,
45
+ subject: client.clientId,
46
+ scopes,
47
+ resource,
48
+ createdAt: now,
49
+ refreshedAt: now,
50
+ revokedAt: null,
51
+ };
52
+ await config.store.putGrant(grant);
53
+ // `clientSeconds` is non-null whenever this grant is enabled: the config refuses otherwise.
54
+ return tokenAnswer(config, grant, config.clientSeconds ?? config.accessSeconds, null);
55
+ }
@@ -0,0 +1,188 @@
1
+ // Single responsibility: RFC 8628 — a CLI on a headless box gets a short user code to type into
2
+ // the app's own verification page while it polls. Both codes are stored hashed; the user code is
3
+ // 8 characters from a no-lookalike alphabet, and every guess at one is counted per caller key.
4
+ // Polling faster than `interval` is `slow_down`; a decision is final and spent exactly once.
5
+
6
+ import type { ResolvedAuthServerConfig } from './authorization-server-config';
7
+ import { consentInvalid } from './authorization-server-errors';
8
+ import { screenResource, screenScopes } from './authorization-server-request';
9
+ import {
10
+ firstRefresh,
11
+ refused,
12
+ type TokenOutcome,
13
+ tokenAnswer,
14
+ } from './authorization-server-tokens';
15
+ import type { AuthServerClient } from './authorization-server-types';
16
+ import { DEVICE_GRANT } from './authorization-server-types';
17
+ import { randomBytes, randomToken, sha256Hex } from './tokens';
18
+
19
+ /** RFC 8628 §6.1's consonant alphabet: no vowels (no words), no 0/O, 1/I/L lookalikes. */
20
+ export const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ';
21
+ export const USER_CODE_LENGTH = 8;
22
+ export const DEVICE_INTERVAL_SECONDS = 5;
23
+
24
+ /** Rejection sampling: `byte % 20` over all 256 values would bias the first 16 letters. */
25
+ export function userCode(): string {
26
+ let code = '';
27
+ while (code.length < USER_CODE_LENGTH) {
28
+ for (const byte of randomBytes(16)) {
29
+ if (byte >= 240 || code.length === USER_CODE_LENGTH) continue;
30
+ code += USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length];
31
+ }
32
+ }
33
+ return code;
34
+ }
35
+
36
+ /** What a person types: any case, the dash optional, spaces ignored. */
37
+ export const normaliseUserCode = (typed: string): string =>
38
+ typed.toUpperCase().replaceAll(/[\s-]/g, '');
39
+
40
+ export async function startDevice(
41
+ config: ResolvedAuthServerConfig,
42
+ form: ReadonlyMap<string, string>,
43
+ client: AuthServerClient,
44
+ ): Promise<TokenOutcome> {
45
+ if (!client.grantTypes.includes(DEVICE_GRANT)) {
46
+ return refused('unauthorized_client', 'this client may not use the device grant');
47
+ }
48
+ const scopes = screenScopes(config, client, form.get('scope'));
49
+ if (scopes === null || scopes.length === 0) {
50
+ return refused('invalid_scope', 'a requested scope is not one this client may be granted');
51
+ }
52
+ const resource = screenResource(config, form.get('resource'));
53
+ if (resource === false) {
54
+ return refused('invalid_target', 'the resource is not one this server issues tokens for');
55
+ }
56
+ const deviceCode = randomToken(32);
57
+ const user = userCode();
58
+ const now = config.clock.now();
59
+ await config.store.putDevice({
60
+ deviceCodeHash: sha256Hex(deviceCode),
61
+ userCodeHash: sha256Hex(user),
62
+ clientId: client.clientId,
63
+ scopes,
64
+ resource,
65
+ status: 'pending',
66
+ subject: null,
67
+ grantedScopes: null,
68
+ intervalSeconds: DEVICE_INTERVAL_SECONDS,
69
+ lastPolledAt: null,
70
+ createdAt: now,
71
+ expiresAt: new Date(now.getTime() + config.deviceSeconds * 1000),
72
+ });
73
+ const page = `${config.origin}${config.devicePath ?? ''}`;
74
+ const shown = `${user.slice(0, 4)}-${user.slice(4)}`;
75
+ return {
76
+ ok: true,
77
+ body: {
78
+ device_code: deviceCode,
79
+ user_code: shown,
80
+ verification_uri: page,
81
+ verification_uri_complete: `${page}?user_code=${shown}`,
82
+ expires_in: config.deviceSeconds,
83
+ interval: DEVICE_INTERVAL_SECONDS,
84
+ },
85
+ };
86
+ }
87
+
88
+ export type DeviceReading =
89
+ | {
90
+ readonly ok: true;
91
+ readonly clientId: string;
92
+ readonly scopes: readonly string[];
93
+ readonly resource: string | null;
94
+ }
95
+ | { readonly ok: false; readonly reason: 'unknown' | 'expired' | 'decided' };
96
+
97
+ /**
98
+ * The verification page's read. `key` is the caller's bucket (address or session): a miss counts
99
+ * against it, so walking the code space locks the walker out (`X_ACCOUNT_LOCKED`) long before the
100
+ * 20^8 codes give way. A hit gives its reservation back.
101
+ */
102
+ export async function readDevice(
103
+ config: ResolvedAuthServerConfig,
104
+ typed: string,
105
+ key: string,
106
+ ): Promise<DeviceReading> {
107
+ const reservation = await config.limiter.reserve(`oauth-device:${key}`);
108
+ const device = await config.store.deviceByUserCode(sha256Hex(normaliseUserCode(typed)));
109
+ if (device === null) return { ok: false, reason: 'unknown' };
110
+ await config.limiter.refund(reservation);
111
+ if (device.expiresAt.getTime() <= config.clock.now().getTime()) {
112
+ return { ok: false, reason: 'expired' };
113
+ }
114
+ if (device.status !== 'pending') return { ok: false, reason: 'decided' };
115
+ return { ok: true, clientId: device.clientId, scopes: device.scopes, resource: device.resource };
116
+ }
117
+
118
+ export async function decideDevice(
119
+ config: ResolvedAuthServerConfig,
120
+ input: {
121
+ readonly userCode: string;
122
+ readonly key: string;
123
+ readonly approve: { readonly subject: string; readonly scopes: readonly string[] } | null;
124
+ },
125
+ ): Promise<void> {
126
+ const reading = await readDevice(config, input.userCode, input.key);
127
+ if (!reading.ok) throw consentInvalid(`the user code is ${reading.reason}`);
128
+ const approved = input.approve?.scopes;
129
+ if (
130
+ approved !== undefined &&
131
+ (approved.length === 0 || !approved.every((s) => reading.scopes.includes(s)))
132
+ ) {
133
+ throw consentInvalid(
134
+ 'the approved scopes are empty or not a subset of what the device asked for',
135
+ );
136
+ }
137
+ const decided = await config.store.decideDevice(
138
+ sha256Hex(normaliseUserCode(input.userCode)),
139
+ input.approve === null
140
+ ? { status: 'denied' }
141
+ : { status: 'approved', subject: input.approve.subject, scopes: input.approve.scopes },
142
+ config.clock.now(),
143
+ );
144
+ if (decided === null) throw consentInvalid('the user code was decided or expired meanwhile');
145
+ }
146
+
147
+ /** The device's poll on `POST /oauth/token`. */
148
+ export async function pollDevice(
149
+ config: ResolvedAuthServerConfig,
150
+ form: ReadonlyMap<string, string>,
151
+ client: AuthServerClient,
152
+ ): Promise<TokenOutcome> {
153
+ const deviceCode = form.get('device_code');
154
+ if (deviceCode === undefined) return refused('invalid_request', 'device_code is required');
155
+ const now = config.clock.now();
156
+ const hash = sha256Hex(deviceCode);
157
+ const device = await config.store.pollDevice(hash, now);
158
+ if (device === null || device.clientId !== client.clientId) {
159
+ return refused('invalid_grant', 'the device code is unknown');
160
+ }
161
+ if (device.expiresAt.getTime() <= now.getTime()) {
162
+ return refused('expired_token', 'the device code expired; start again');
163
+ }
164
+ const last = device.lastPolledAt;
165
+ if (last !== null && now.getTime() - last.getTime() < device.intervalSeconds * 1000) {
166
+ return refused('slow_down', `poll no faster than every ${device.intervalSeconds} seconds`);
167
+ }
168
+ if (device.status === 'pending') return refused('authorization_pending', 'not decided yet');
169
+ if (device.status === 'denied') return refused('access_denied', 'the person declined');
170
+ if (device.status === 'consumed' || device.subject === null) {
171
+ return refused('invalid_grant', 'the device code was already used');
172
+ }
173
+ if (!(await config.store.consumeDevice(hash, now))) {
174
+ return refused('invalid_grant', 'the device code was already used');
175
+ }
176
+ const grant = {
177
+ id: `g_${randomToken(16)}`,
178
+ clientId: client.clientId,
179
+ subject: device.subject,
180
+ scopes: device.grantedScopes ?? [],
181
+ resource: device.resource,
182
+ createdAt: now,
183
+ refreshedAt: now,
184
+ revokedAt: null,
185
+ };
186
+ await config.store.putGrant(grant);
187
+ return tokenAnswer(config, grant, config.accessSeconds, await firstRefresh(config, grant.id));
188
+ }