@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.
- package/CLAUDE.md +46 -58
- package/README.md +162 -9
- package/package.json +20 -5
- package/src/adapter.ts +88 -2
- package/src/api-key-resolver.ts +3 -3
- package/src/auth.ts +29 -7
- package/src/authorization-server-clients.ts +169 -0
- package/src/authorization-server-config.ts +239 -0
- package/src/authorization-server-errors.ts +23 -0
- package/src/authorization-server-postgres-sql.ts +107 -0
- package/src/authorization-server-postgres.ts +222 -0
- package/src/authorization-server-register.ts +98 -0
- package/src/authorization-server-request.ts +126 -0
- package/src/authorization-server-routes.ts +181 -0
- package/src/authorization-server-schema.ts +82 -0
- package/src/authorization-server-store.ts +189 -0
- package/src/authorization-server-tokens.ts +118 -0
- package/src/authorization-server-types.ts +108 -0
- package/src/authorization-server-wire.ts +135 -0
- package/src/authorization-server.ts +178 -0
- package/src/base64url.ts +40 -0
- package/src/builtin-adapter-violation.ts +12 -9
- package/src/builtin-adapter.ts +31 -5
- package/src/cbor-encode.ts +52 -0
- package/src/cbor.ts +129 -0
- package/src/client-scope.ts +2 -5
- package/src/client.ts +16 -0
- package/src/cose-key.ts +129 -0
- package/src/errors.ts +51 -0
- package/src/grant-authorization-code.ts +113 -0
- package/src/grant-client-credentials.ts +55 -0
- package/src/grant-device.ts +188 -0
- package/src/index.ts +108 -3
- package/src/jwks.ts +81 -12
- package/src/jwt-bearer-resolver.ts +80 -0
- package/src/limiter-install.ts +31 -2
- package/src/memory-adapter.ts +18 -0
- package/src/mfa-challenge.ts +13 -7
- package/src/mfa-replay-postgres.ts +71 -0
- package/src/mfa.ts +24 -13
- package/src/oauth-bearer-resolver.ts +44 -0
- package/src/oauth-cookie.ts +2 -1
- package/src/oauth-discovery.ts +17 -6
- package/src/oauth-exchange.ts +23 -12
- package/src/oauth-profile.ts +20 -9
- package/src/oauth-route.ts +5 -5
- package/src/passkey-assertion.ts +60 -0
- package/src/passkey-challenge.ts +59 -0
- package/src/passkey-client.ts +156 -0
- package/src/passkey-config.ts +92 -0
- package/src/passkey-errors.ts +89 -0
- package/src/passkey-evidence.ts +74 -0
- package/src/passkey-login.ts +127 -0
- package/src/passkey-memory.ts +77 -0
- package/src/passkey-postgres.ts +155 -0
- package/src/passkey-step-up.ts +135 -0
- package/src/passkey-verify.ts +180 -0
- package/src/passkey-wire.ts +61 -0
- package/src/soft-authenticator.ts +208 -0
- package/src/subject-target.ts +73 -0
- package/src/tables.ts +46 -0
- package/src/token-endpoints.ts +61 -0
- package/src/tokens.ts +2 -25
- package/src/verification-purposes.ts +61 -0
- package/src/verify.ts +86 -12
- package/src/webauthn-data.ts +111 -0
- package/src/workload.ts +67 -15
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The DDL of the authorization server's five framework tables, and nothing else: a leaf, so the
|
|
2
|
+
// boot's `FRAMEWORK_SCHEMA` reads it beside `AUTH_TABLES` without the server's logic. Every secret
|
|
3
|
+
// column holds a SHA-256 digest, never the credential. Lists are SPACE-joined text: scope-tokens
|
|
4
|
+
// and URIs hold no space. Imports nothing.
|
|
5
|
+
|
|
6
|
+
export const AUTH_SERVER_TABLES = [
|
|
7
|
+
'x_oauth_clients',
|
|
8
|
+
'x_oauth_codes',
|
|
9
|
+
'x_oauth_grants',
|
|
10
|
+
'x_oauth_tokens',
|
|
11
|
+
'x_oauth_devices',
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Applied by the boot, never by an app migration. `create … if not exists` throughout; a new
|
|
16
|
+
* column is an `alter table … add column if not exists`, never an edit to a `create`.
|
|
17
|
+
*/
|
|
18
|
+
export const SQL_AUTH_SERVER_TABLES = `
|
|
19
|
+
create table if not exists x_oauth_clients (
|
|
20
|
+
client_id text primary key,
|
|
21
|
+
name text not null,
|
|
22
|
+
secret_hash text,
|
|
23
|
+
auth_method text not null,
|
|
24
|
+
redirect_uris text not null default '',
|
|
25
|
+
grant_types text not null default '',
|
|
26
|
+
scopes text not null default '',
|
|
27
|
+
resources text not null default '',
|
|
28
|
+
created_at timestamptz not null
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
create table if not exists x_oauth_grants (
|
|
32
|
+
id text primary key,
|
|
33
|
+
client_id text not null,
|
|
34
|
+
subject text not null,
|
|
35
|
+
scopes text not null,
|
|
36
|
+
resource text,
|
|
37
|
+
created_at timestamptz not null,
|
|
38
|
+
refreshed_at timestamptz not null,
|
|
39
|
+
revoked_at timestamptz
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
create index if not exists x_oauth_grants_subject_idx on x_oauth_grants (subject, client_id);
|
|
43
|
+
|
|
44
|
+
create table if not exists x_oauth_codes (
|
|
45
|
+
code_hash text primary key,
|
|
46
|
+
grant_id text not null,
|
|
47
|
+
client_id text not null,
|
|
48
|
+
subject text not null,
|
|
49
|
+
scopes text not null,
|
|
50
|
+
resource text,
|
|
51
|
+
redirect_uri text not null,
|
|
52
|
+
code_challenge text not null,
|
|
53
|
+
expires_at timestamptz not null,
|
|
54
|
+
used_at timestamptz
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
create table if not exists x_oauth_tokens (
|
|
58
|
+
token_hash text primary key,
|
|
59
|
+
kind text not null,
|
|
60
|
+
grant_id text not null,
|
|
61
|
+
created_at timestamptz not null,
|
|
62
|
+
expires_at timestamptz not null,
|
|
63
|
+
retired_at timestamptz
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
create index if not exists x_oauth_tokens_grant_idx on x_oauth_tokens (grant_id);
|
|
67
|
+
|
|
68
|
+
create table if not exists x_oauth_devices (
|
|
69
|
+
device_code_hash text primary key,
|
|
70
|
+
user_code_hash text not null unique,
|
|
71
|
+
client_id text not null,
|
|
72
|
+
scopes text not null,
|
|
73
|
+
resource text,
|
|
74
|
+
status text not null default 'pending',
|
|
75
|
+
subject text,
|
|
76
|
+
granted_scopes text,
|
|
77
|
+
interval_seconds integer not null,
|
|
78
|
+
last_polled_at timestamptz,
|
|
79
|
+
created_at timestamptz not null,
|
|
80
|
+
expires_at timestamptz not null
|
|
81
|
+
);
|
|
82
|
+
`;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Single responsibility: the contract an authorization server's storage answers, and the memory
|
|
2
|
+
// driver. Every single-use rule is a STORAGE guarantee — a code taken once, a refresh token rotated
|
|
3
|
+
// once, a device code consumed once — so each is one atomic method, never a read then a write.
|
|
4
|
+
// Postgres is `authorization-server-postgres.ts`; the contract test holds the two to one answer.
|
|
5
|
+
|
|
6
|
+
import { timingSafeEqual } from '@ultimat3/core';
|
|
7
|
+
import type {
|
|
8
|
+
AuthCodeRecord,
|
|
9
|
+
AuthGrantRecord,
|
|
10
|
+
AuthServerClient,
|
|
11
|
+
AuthTokenRecord,
|
|
12
|
+
DeviceCodeRecord,
|
|
13
|
+
} from './authorization-server-types';
|
|
14
|
+
|
|
15
|
+
export type RefreshRotation =
|
|
16
|
+
| { readonly outcome: 'rotated'; readonly grantId: string }
|
|
17
|
+
/** The token was already retired: somebody holds a copy. The caller revokes `grantId`. */
|
|
18
|
+
| { readonly outcome: 'replayed'; readonly grantId: string }
|
|
19
|
+
| { readonly outcome: 'unknown' };
|
|
20
|
+
|
|
21
|
+
export type DeviceDecision =
|
|
22
|
+
| { readonly status: 'approved'; readonly subject: string; readonly scopes: readonly string[] }
|
|
23
|
+
| { readonly status: 'denied' };
|
|
24
|
+
|
|
25
|
+
export interface AuthServerStore {
|
|
26
|
+
putClient(client: AuthServerClient): Promise<void>;
|
|
27
|
+
getClient(clientId: string): Promise<AuthServerClient | null>;
|
|
28
|
+
putCode(code: AuthCodeRecord): Promise<void>;
|
|
29
|
+
/** Marks the code used at `at` in the step that reads it. `fresh: false` is a replay. */
|
|
30
|
+
takeCode(
|
|
31
|
+
codeHash: string,
|
|
32
|
+
at: Date,
|
|
33
|
+
): Promise<{ readonly code: AuthCodeRecord; readonly fresh: boolean } | null>;
|
|
34
|
+
putGrant(grant: AuthGrantRecord): Promise<void>;
|
|
35
|
+
getGrant(id: string): Promise<AuthGrantRecord | null>;
|
|
36
|
+
/** Restarts the idle window. */
|
|
37
|
+
touchGrant(id: string, at: Date): Promise<void>;
|
|
38
|
+
revokeGrant(id: string, at: Date): Promise<void>;
|
|
39
|
+
/** Every live grant of `subject` (of one client when named). Answers how many. */
|
|
40
|
+
revokeGrants(
|
|
41
|
+
filter: { subject: string; clientId?: string | undefined },
|
|
42
|
+
at: Date,
|
|
43
|
+
): Promise<number>;
|
|
44
|
+
putToken(token: AuthTokenRecord): Promise<void>;
|
|
45
|
+
getToken(tokenHash: string): Promise<AuthTokenRecord | null>;
|
|
46
|
+
/** Retire the live refresh token `tokenHash` and store `next`, atomically. */
|
|
47
|
+
rotateRefresh(tokenHash: string, next: AuthTokenRecord, at: Date): Promise<RefreshRotation>;
|
|
48
|
+
putDevice(device: DeviceCodeRecord): Promise<void>;
|
|
49
|
+
deviceByUserCode(userCodeHash: string): Promise<DeviceCodeRecord | null>;
|
|
50
|
+
/** Pending and unexpired at `at` → decided. `null` when it was not in that state. */
|
|
51
|
+
decideDevice(
|
|
52
|
+
userCodeHash: string,
|
|
53
|
+
decision: DeviceDecision,
|
|
54
|
+
at: Date,
|
|
55
|
+
): Promise<DeviceCodeRecord | null>;
|
|
56
|
+
/** Stamps `lastPolledAt = at` and answers the row as it was BEFORE the stamp. */
|
|
57
|
+
pollDevice(deviceCodeHash: string, at: Date): Promise<DeviceCodeRecord | null>;
|
|
58
|
+
/** Approved → consumed. `false` when another poll took it first. */
|
|
59
|
+
consumeDevice(deviceCodeHash: string, at: Date): Promise<boolean>;
|
|
60
|
+
/**
|
|
61
|
+
* Deletes codes, tokens and device codes expired before `before`, grants revoked before it, and
|
|
62
|
+
* grants last refreshed before `idleBefore` (past the idle window, so unrefreshable).
|
|
63
|
+
*/
|
|
64
|
+
purge(before: Date, idleBefore: Date): Promise<number>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Per PROCESS: right for a test and `x dev`, wrong for a fleet — a code minted on one replica is
|
|
69
|
+
* redeemed on another. A deployment passes `postgresAuthServerStore({ executor })`.
|
|
70
|
+
*/
|
|
71
|
+
export function memoryAuthServerStore(): AuthServerStore {
|
|
72
|
+
const clients = new Map<string, AuthServerClient>();
|
|
73
|
+
const codes = new Map<string, AuthCodeRecord>();
|
|
74
|
+
const grants = new Map<string, AuthGrantRecord>();
|
|
75
|
+
const tokens = new Map<string, AuthTokenRecord>();
|
|
76
|
+
const devices = new Map<string, DeviceCodeRecord>();
|
|
77
|
+
const deviceOfUserCode = (hash: string): DeviceCodeRecord | undefined => {
|
|
78
|
+
for (const device of devices.values()) {
|
|
79
|
+
if (timingSafeEqual(device.userCodeHash, hash)) return device;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
};
|
|
83
|
+
const expired = (row: { readonly expiresAt: Date }, before: Date): boolean =>
|
|
84
|
+
row.expiresAt.getTime() < before.getTime();
|
|
85
|
+
return {
|
|
86
|
+
async putClient(client) {
|
|
87
|
+
clients.set(client.clientId, client);
|
|
88
|
+
},
|
|
89
|
+
getClient: async (id) => clients.get(id) ?? null,
|
|
90
|
+
async putCode(code) {
|
|
91
|
+
codes.set(code.codeHash, code);
|
|
92
|
+
},
|
|
93
|
+
async takeCode(hash, at) {
|
|
94
|
+
const code = codes.get(hash);
|
|
95
|
+
if (code === undefined) return null;
|
|
96
|
+
if (code.usedAt !== null) return { code, fresh: false };
|
|
97
|
+
codes.set(hash, { ...code, usedAt: at });
|
|
98
|
+
return { code, fresh: true };
|
|
99
|
+
},
|
|
100
|
+
async putGrant(grant) {
|
|
101
|
+
grants.set(grant.id, grant);
|
|
102
|
+
},
|
|
103
|
+
getGrant: async (id) => grants.get(id) ?? null,
|
|
104
|
+
async touchGrant(id, at) {
|
|
105
|
+
const grant = grants.get(id);
|
|
106
|
+
if (grant !== undefined) grants.set(id, { ...grant, refreshedAt: at });
|
|
107
|
+
},
|
|
108
|
+
async revokeGrant(id, at) {
|
|
109
|
+
const grant = grants.get(id);
|
|
110
|
+
if (grant !== undefined && grant.revokedAt === null) {
|
|
111
|
+
grants.set(id, { ...grant, revokedAt: at });
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
async revokeGrants(filter, at) {
|
|
115
|
+
let revoked = 0;
|
|
116
|
+
for (const grant of grants.values()) {
|
|
117
|
+
if (grant.revokedAt !== null || grant.subject !== filter.subject) continue;
|
|
118
|
+
if (filter.clientId !== undefined && grant.clientId !== filter.clientId) continue;
|
|
119
|
+
grants.set(grant.id, { ...grant, revokedAt: at });
|
|
120
|
+
revoked += 1;
|
|
121
|
+
}
|
|
122
|
+
return revoked;
|
|
123
|
+
},
|
|
124
|
+
async putToken(token) {
|
|
125
|
+
tokens.set(token.tokenHash, token);
|
|
126
|
+
},
|
|
127
|
+
getToken: async (hash) => tokens.get(hash) ?? null,
|
|
128
|
+
async rotateRefresh(hash, next, at) {
|
|
129
|
+
const token = tokens.get(hash);
|
|
130
|
+
if (token === undefined || token.kind !== 'refresh') return { outcome: 'unknown' };
|
|
131
|
+
if (token.retiredAt !== null) return { outcome: 'replayed', grantId: token.grantId };
|
|
132
|
+
tokens.set(hash, { ...token, retiredAt: at });
|
|
133
|
+
tokens.set(next.tokenHash, next);
|
|
134
|
+
return { outcome: 'rotated', grantId: token.grantId };
|
|
135
|
+
},
|
|
136
|
+
async putDevice(device) {
|
|
137
|
+
devices.set(device.deviceCodeHash, device);
|
|
138
|
+
},
|
|
139
|
+
deviceByUserCode: async (hash) => deviceOfUserCode(hash) ?? null,
|
|
140
|
+
async decideDevice(hash, decision, at) {
|
|
141
|
+
const device = deviceOfUserCode(hash);
|
|
142
|
+
if (device === undefined || device.status !== 'pending') return null;
|
|
143
|
+
if (device.expiresAt.getTime() <= at.getTime()) return null;
|
|
144
|
+
const decided: DeviceCodeRecord =
|
|
145
|
+
decision.status === 'approved'
|
|
146
|
+
? {
|
|
147
|
+
...device,
|
|
148
|
+
status: 'approved',
|
|
149
|
+
subject: decision.subject,
|
|
150
|
+
grantedScopes: decision.scopes,
|
|
151
|
+
}
|
|
152
|
+
: { ...device, status: 'denied' };
|
|
153
|
+
devices.set(device.deviceCodeHash, decided);
|
|
154
|
+
return decided;
|
|
155
|
+
},
|
|
156
|
+
async pollDevice(hash, at) {
|
|
157
|
+
const device = devices.get(hash);
|
|
158
|
+
if (device === undefined) return null;
|
|
159
|
+
devices.set(hash, { ...device, lastPolledAt: at });
|
|
160
|
+
return device;
|
|
161
|
+
},
|
|
162
|
+
async consumeDevice(hash, _at) {
|
|
163
|
+
const device = devices.get(hash);
|
|
164
|
+
if (device === undefined || device.status !== 'approved') return false;
|
|
165
|
+
devices.set(hash, { ...device, status: 'consumed' });
|
|
166
|
+
return true;
|
|
167
|
+
},
|
|
168
|
+
async purge(before, idleBefore) {
|
|
169
|
+
let purged = 0;
|
|
170
|
+
const sweep = <T>(rows: Map<string, T>, gone: (row: T) => boolean): void => {
|
|
171
|
+
for (const [key, row] of [...rows]) {
|
|
172
|
+
if (!gone(row)) continue;
|
|
173
|
+
rows.delete(key);
|
|
174
|
+
purged += 1;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
sweep(codes, (row) => expired(row, before));
|
|
178
|
+
sweep(tokens, (row) => expired(row, before));
|
|
179
|
+
sweep(devices, (row) => expired(row, before));
|
|
180
|
+
sweep(
|
|
181
|
+
grants,
|
|
182
|
+
(row) =>
|
|
183
|
+
(row.revokedAt !== null && row.revokedAt.getTime() < before.getTime()) ||
|
|
184
|
+
row.refreshedAt.getTime() < idleBefore.getTime(),
|
|
185
|
+
);
|
|
186
|
+
return purged;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Single responsibility: mint, answer and resolve the server's tokens. Opaque and stored hashed —
|
|
2
|
+
// never a JWT — so a revocation holds on the very next lookup, and a database read leaks no
|
|
3
|
+
// usable credential. PKCE's S256 check lives here too, compared in constant time.
|
|
4
|
+
|
|
5
|
+
import { timingSafeEqual } from '@ultimat3/core';
|
|
6
|
+
import type { ResolvedAuthServerConfig } from './authorization-server-config';
|
|
7
|
+
import type { AuthGrantRecord, AuthTokenRecord, ResolvedGrant } from './authorization-server-types';
|
|
8
|
+
import { base64Url, randomToken, sha256Hex } from './tokens';
|
|
9
|
+
|
|
10
|
+
/** Prefixes a secret scanner can match. Carry nothing; the token is 32 random bytes after them. */
|
|
11
|
+
export const ACCESS_TOKEN_PREFIX = 'xat_';
|
|
12
|
+
export const REFRESH_TOKEN_PREFIX = 'xrt_';
|
|
13
|
+
|
|
14
|
+
export type TokenOutcome =
|
|
15
|
+
| { readonly ok: true; readonly body: Readonly<Record<string, unknown>> }
|
|
16
|
+
| {
|
|
17
|
+
readonly ok: false;
|
|
18
|
+
/** RFC 6749 §5.2 (or RFC 8628 §3.5) error. */
|
|
19
|
+
readonly error: string;
|
|
20
|
+
readonly description: string;
|
|
21
|
+
/** 400 unless the client failed to authenticate (401). */
|
|
22
|
+
readonly status: 400 | 401;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const refused = (
|
|
26
|
+
error: string,
|
|
27
|
+
description: string,
|
|
28
|
+
status: 400 | 401 = 400,
|
|
29
|
+
): TokenOutcome => ({
|
|
30
|
+
ok: false,
|
|
31
|
+
error,
|
|
32
|
+
description,
|
|
33
|
+
status,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** RFC 7636 §4.6: `BASE64URL(SHA256(ASCII(code_verifier))) == code_challenge`. */
|
|
37
|
+
export function pkceMatches(verifier: string, challenge: string): boolean {
|
|
38
|
+
if (!/^[A-Za-z0-9\-._~]{43,128}$/.test(verifier)) return false;
|
|
39
|
+
const digest = new Bun.CryptoHasher('sha256').update(verifier).digest();
|
|
40
|
+
return timingSafeEqual(base64Url(new Uint8Array(digest)), challenge);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const tokenRecord = (
|
|
44
|
+
config: ResolvedAuthServerConfig,
|
|
45
|
+
kind: AuthTokenRecord['kind'],
|
|
46
|
+
grantId: string,
|
|
47
|
+
lifeMs: number,
|
|
48
|
+
): { readonly token: string; readonly record: AuthTokenRecord } => {
|
|
49
|
+
const token = `${kind === 'access' ? ACCESS_TOKEN_PREFIX : REFRESH_TOKEN_PREFIX}${randomToken(32)}`;
|
|
50
|
+
const now = config.clock.now();
|
|
51
|
+
return {
|
|
52
|
+
token,
|
|
53
|
+
record: {
|
|
54
|
+
tokenHash: sha256Hex(token),
|
|
55
|
+
kind,
|
|
56
|
+
grantId,
|
|
57
|
+
createdAt: now,
|
|
58
|
+
expiresAt: new Date(now.getTime() + lifeMs),
|
|
59
|
+
retiredAt: null,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** A refresh token's row: it lives as long as the idle window, which a rotation restarts. */
|
|
65
|
+
export const refreshRecord = (config: ResolvedAuthServerConfig, grantId: string) =>
|
|
66
|
+
tokenRecord(config, 'refresh', grantId, config.refreshIdleMs);
|
|
67
|
+
|
|
68
|
+
/** An access token (stored), plus the refresh token when one was already minted for the answer. */
|
|
69
|
+
export async function tokenAnswer(
|
|
70
|
+
config: ResolvedAuthServerConfig,
|
|
71
|
+
grant: AuthGrantRecord,
|
|
72
|
+
accessSeconds: number,
|
|
73
|
+
refreshToken: string | null,
|
|
74
|
+
): Promise<TokenOutcome> {
|
|
75
|
+
const access = tokenRecord(config, 'access', grant.id, accessSeconds * 1000);
|
|
76
|
+
await config.store.putToken(access.record);
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
body: {
|
|
80
|
+
access_token: access.token,
|
|
81
|
+
token_type: 'Bearer',
|
|
82
|
+
expires_in: accessSeconds,
|
|
83
|
+
scope: grant.scopes.join(' '),
|
|
84
|
+
...(refreshToken === null ? {} : { refresh_token: refreshToken }),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A new refresh token, stored, for a grant that has none yet. */
|
|
90
|
+
export async function firstRefresh(
|
|
91
|
+
config: ResolvedAuthServerConfig,
|
|
92
|
+
grantId: string,
|
|
93
|
+
): Promise<string | null> {
|
|
94
|
+
if (!config.grants.has('refresh_token')) return null;
|
|
95
|
+
const refresh = refreshRecord(config, grantId);
|
|
96
|
+
await config.store.putToken(refresh.record);
|
|
97
|
+
return refresh.token;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A live access token's grant. `null` for unknown, expired, a refresh token, or revoked. */
|
|
101
|
+
export async function resolveAccess(
|
|
102
|
+
config: ResolvedAuthServerConfig,
|
|
103
|
+
token: string,
|
|
104
|
+
): Promise<{ readonly grant: AuthGrantRecord; readonly token: AuthTokenRecord } | null> {
|
|
105
|
+
const record = await config.store.getToken(sha256Hex(token));
|
|
106
|
+
if (record === null || record.kind !== 'access') return null;
|
|
107
|
+
if (record.expiresAt.getTime() <= config.clock.now().getTime()) return null;
|
|
108
|
+
const grant = await config.store.getGrant(record.grantId);
|
|
109
|
+
if (grant === null || grant.revokedAt !== null) return null;
|
|
110
|
+
return { grant, token: record };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const resolvedGrant = (grant: AuthGrantRecord): ResolvedGrant => ({
|
|
114
|
+
subject: grant.subject,
|
|
115
|
+
scopes: new Set(grant.scopes),
|
|
116
|
+
clientId: grant.clientId,
|
|
117
|
+
resource: grant.resource,
|
|
118
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Single responsibility: the records an authorization server keeps — clients, codes, grants, tokens
|
|
2
|
+
// and device codes — and the screened request a consent page shows. Data only; the store contract
|
|
3
|
+
// is `authorization-server-store.ts`, the rules that fill these are the grant modules.
|
|
4
|
+
|
|
5
|
+
/** The grants a server may enable. `authorization_code` and `refresh_token` are the default pair. */
|
|
6
|
+
export type AuthServerGrant =
|
|
7
|
+
| 'authorization_code'
|
|
8
|
+
| 'refresh_token'
|
|
9
|
+
| 'client_credentials'
|
|
10
|
+
| 'urn:ietf:params:oauth:grant-type:device_code';
|
|
11
|
+
|
|
12
|
+
export const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
13
|
+
|
|
14
|
+
/** `none` is a public client (PKCE is its proof); the two secret methods are a confidential one. */
|
|
15
|
+
export type ClientAuthMethod = 'none' | 'client_secret_basic' | 'client_secret_post';
|
|
16
|
+
|
|
17
|
+
export interface AuthServerClient {
|
|
18
|
+
/** Opaque for a registered client; the https URL itself for a metadata-document client. */
|
|
19
|
+
readonly clientId: string;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** `sha256Hex` of the secret, `null` for a public client. The secret itself is never stored. */
|
|
22
|
+
readonly secretHash: string | null;
|
|
23
|
+
readonly authMethod: ClientAuthMethod;
|
|
24
|
+
/** Compared byte for byte. Never a prefix, never a pattern, never normalised. */
|
|
25
|
+
readonly redirectUris: readonly string[];
|
|
26
|
+
readonly grantTypes: readonly AuthServerGrant[];
|
|
27
|
+
/** What the client may ever be granted; a request narrows within it. Empty = the server's set. */
|
|
28
|
+
readonly scopes: readonly string[];
|
|
29
|
+
/** Resource indicators this client SERVES — what its introspection may see. */
|
|
30
|
+
readonly resources: readonly string[];
|
|
31
|
+
readonly createdAt: Date;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What `readRequest` screened and `approve` re-screens: the consent page's whole input. */
|
|
35
|
+
export interface AuthorizationRequest {
|
|
36
|
+
readonly client: AuthServerClient;
|
|
37
|
+
readonly redirectUri: string;
|
|
38
|
+
readonly scopes: readonly string[];
|
|
39
|
+
/** RFC 8707 resource indicator, `null` when the client named none. */
|
|
40
|
+
readonly resource: string | null;
|
|
41
|
+
readonly state: string | null;
|
|
42
|
+
readonly codeChallenge: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AuthCodeRecord {
|
|
46
|
+
readonly codeHash: string;
|
|
47
|
+
/** Pre-assigned: a replayed code revokes the grant this id names, whatever it minted. */
|
|
48
|
+
readonly grantId: string;
|
|
49
|
+
readonly clientId: string;
|
|
50
|
+
readonly subject: string;
|
|
51
|
+
readonly scopes: readonly string[];
|
|
52
|
+
readonly resource: string | null;
|
|
53
|
+
readonly redirectUri: string;
|
|
54
|
+
readonly codeChallenge: string;
|
|
55
|
+
readonly expiresAt: Date;
|
|
56
|
+
readonly usedAt: Date | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AuthGrantRecord {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly clientId: string;
|
|
62
|
+
/** A person for the code and device grants, the client itself for `client_credentials`. */
|
|
63
|
+
readonly subject: string;
|
|
64
|
+
readonly scopes: readonly string[];
|
|
65
|
+
readonly resource: string | null;
|
|
66
|
+
readonly createdAt: Date;
|
|
67
|
+
/** The last refresh; the idle window runs from here. */
|
|
68
|
+
readonly refreshedAt: Date;
|
|
69
|
+
readonly revokedAt: Date | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type AuthTokenKind = 'access' | 'refresh';
|
|
73
|
+
|
|
74
|
+
export interface AuthTokenRecord {
|
|
75
|
+
readonly tokenHash: string;
|
|
76
|
+
readonly kind: AuthTokenKind;
|
|
77
|
+
readonly grantId: string;
|
|
78
|
+
readonly createdAt: Date;
|
|
79
|
+
readonly expiresAt: Date;
|
|
80
|
+
/** A rotated refresh token: kept so its replay is recognised, never honoured. */
|
|
81
|
+
readonly retiredAt: Date | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type DeviceStatus = 'pending' | 'approved' | 'denied' | 'consumed';
|
|
85
|
+
|
|
86
|
+
export interface DeviceCodeRecord {
|
|
87
|
+
readonly deviceCodeHash: string;
|
|
88
|
+
readonly userCodeHash: string;
|
|
89
|
+
readonly clientId: string;
|
|
90
|
+
readonly scopes: readonly string[];
|
|
91
|
+
readonly resource: string | null;
|
|
92
|
+
readonly status: DeviceStatus;
|
|
93
|
+
/** Set on approval: who consented and to what, narrowed. */
|
|
94
|
+
readonly subject: string | null;
|
|
95
|
+
readonly grantedScopes: readonly string[] | null;
|
|
96
|
+
readonly intervalSeconds: number;
|
|
97
|
+
readonly lastPolledAt: Date | null;
|
|
98
|
+
readonly createdAt: Date;
|
|
99
|
+
readonly expiresAt: Date;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What `resolve(token)` answers for a live access token. */
|
|
103
|
+
export interface ResolvedGrant {
|
|
104
|
+
readonly subject: string;
|
|
105
|
+
readonly scopes: ReadonlySet<string>;
|
|
106
|
+
readonly clientId: string;
|
|
107
|
+
readonly resource: string | null;
|
|
108
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Single responsibility: the HTTP edge of the authorization server — the structural route shape
|
|
2
|
+
// (`@ultimat3/http` is tier 2 like this package, so its `Route` is matched by structure, never
|
|
3
|
+
// imported), reading a form body, reading client credentials, and answering RFC 6749 JSON.
|
|
4
|
+
// `no-store` on every answer: a token response cached anywhere is a token handed to a stranger.
|
|
5
|
+
|
|
6
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { ClientCredentials } from './authorization-server-clients';
|
|
8
|
+
import type { TokenOutcome } from './authorization-server-tokens';
|
|
9
|
+
|
|
10
|
+
/** The part of http's `UltimateRequest` these routes read: the request, and its capped body. */
|
|
11
|
+
export interface AuthServerHttpRequest {
|
|
12
|
+
readonly raw: Request;
|
|
13
|
+
bodyBytes(): Promise<Uint8Array>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The part of http's `RequestContext` they read: the pipeline-resolved caller address. */
|
|
17
|
+
export interface AuthServerHttpContext {
|
|
18
|
+
readonly ip: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Assignable to `@ultimat3/http`'s `Route` — the shape `runtime.routes` takes — by structure.
|
|
23
|
+
* Public by definition: they are how a client without a token gets one. No cookie authenticates
|
|
24
|
+
* here; a client authenticates in the body or the `Authorization: Basic` header.
|
|
25
|
+
*/
|
|
26
|
+
export interface AuthServerRoute {
|
|
27
|
+
readonly method: 'GET' | 'POST';
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly handler: (
|
|
30
|
+
request: AuthServerHttpRequest,
|
|
31
|
+
ctx: AuthServerHttpContext,
|
|
32
|
+
) => Promise<Response>;
|
|
33
|
+
readonly meta: {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly auth: 'public';
|
|
36
|
+
/**
|
|
37
|
+
* On every POST: http's `csrf` stage passes a cross-origin client here (the endpoint's whole
|
|
38
|
+
* purpose), and `authenticate` answering `null` keeps the session cookie from deciding anything.
|
|
39
|
+
*/
|
|
40
|
+
readonly csrf?: 'cookieless';
|
|
41
|
+
readonly authenticate?: () => null;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const NO_STORE = { 'cache-control': 'no-store', pragma: 'no-cache' } as const;
|
|
46
|
+
|
|
47
|
+
export const jsonAnswer = (
|
|
48
|
+
status: number,
|
|
49
|
+
body: unknown,
|
|
50
|
+
headers: Readonly<Record<string, string>> = {},
|
|
51
|
+
): Response =>
|
|
52
|
+
Response.json(body, {
|
|
53
|
+
status,
|
|
54
|
+
headers: { ...NO_STORE, ...headers },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** RFC 6749 §5.2's body. A 401 names the scheme the client should retry with (§5.2). */
|
|
58
|
+
export function outcomeAnswer(outcome: TokenOutcome, success = 200): Response {
|
|
59
|
+
if (outcome.ok) return jsonAnswer(success, outcome.body);
|
|
60
|
+
const { status } = outcome;
|
|
61
|
+
return jsonAnswer(
|
|
62
|
+
status,
|
|
63
|
+
{ error: outcome.error, error_description: outcome.description },
|
|
64
|
+
status === 401 ? { 'www-authenticate': 'Basic realm="oauth"' } : {},
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The limiter's `X_ACCOUNT_LOCKED` as RFC 6749 `429 temporarily_unavailable`; `null` otherwise. */
|
|
69
|
+
export function throttledAnswer(error: unknown, description: string): Response | null {
|
|
70
|
+
if (!isUltimateError(error) || error.code !== 'X_ACCOUNT_LOCKED') return null;
|
|
71
|
+
const after = error.meta?.['retryAfterSeconds'];
|
|
72
|
+
return jsonAnswer(
|
|
73
|
+
429,
|
|
74
|
+
{ error: 'temporarily_unavailable', error_description: description },
|
|
75
|
+
typeof after === 'number' ? { 'retry-after': String(after) } : {},
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A form body, one value per name, or the name that repeats / `null` when it is not a form. */
|
|
80
|
+
export async function readForm(
|
|
81
|
+
request: AuthServerHttpRequest,
|
|
82
|
+
): Promise<Map<string, string> | { readonly refused: string }> {
|
|
83
|
+
const type = (request.raw.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase();
|
|
84
|
+
if (type !== 'application/x-www-form-urlencoded') {
|
|
85
|
+
return { refused: 'the body must be application/x-www-form-urlencoded' };
|
|
86
|
+
}
|
|
87
|
+
const params = new URLSearchParams(new TextDecoder().decode(await request.bodyBytes()));
|
|
88
|
+
const form = new Map<string, string>();
|
|
89
|
+
for (const [key, value] of params) {
|
|
90
|
+
if (form.has(key)) return { refused: `the ${key} parameter is repeated` };
|
|
91
|
+
form.set(key, value);
|
|
92
|
+
}
|
|
93
|
+
return form;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* RFC 6749 §2.3.1: `Basic` (each half form-urlencoded first) or `client_id`/`client_secret` in
|
|
98
|
+
* the body — one method per request. `null` with a reason when the request is malformed.
|
|
99
|
+
*/
|
|
100
|
+
export function readClientCredentials(
|
|
101
|
+
request: AuthServerHttpRequest,
|
|
102
|
+
form: ReadonlyMap<string, string>,
|
|
103
|
+
): ClientCredentials | { readonly refused: string } {
|
|
104
|
+
const header = request.raw.headers.get('authorization');
|
|
105
|
+
if (header !== null && /^basic /i.test(header)) {
|
|
106
|
+
if (form.has('client_secret')) return { refused: 'use one client authentication method' };
|
|
107
|
+
let decoded: string;
|
|
108
|
+
try {
|
|
109
|
+
decoded = atob(header.slice(6).trim());
|
|
110
|
+
} catch {
|
|
111
|
+
return { refused: 'the Basic credentials are not base64' };
|
|
112
|
+
}
|
|
113
|
+
const colon = decoded.indexOf(':');
|
|
114
|
+
if (colon < 1) return { refused: 'the Basic credentials carry no client id' };
|
|
115
|
+
const decode = (part: string): string | null => {
|
|
116
|
+
try {
|
|
117
|
+
return decodeURIComponent(part.replaceAll('+', ' '));
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const clientId = decode(decoded.slice(0, colon));
|
|
123
|
+
const secret = decode(decoded.slice(colon + 1));
|
|
124
|
+
if (clientId === null || secret === null)
|
|
125
|
+
return { refused: 'the Basic credentials are malformed' };
|
|
126
|
+
const named = form.get('client_id');
|
|
127
|
+
if (named !== undefined && named !== clientId)
|
|
128
|
+
return { refused: 'client_id disagrees with Basic' };
|
|
129
|
+
return { clientId, secret: secret === '' ? null : secret };
|
|
130
|
+
}
|
|
131
|
+
const clientId = form.get('client_id');
|
|
132
|
+
if (clientId === undefined || clientId === '') return { refused: 'client_id is required' };
|
|
133
|
+
const secret = form.get('client_secret');
|
|
134
|
+
return { clientId, secret: secret === undefined || secret === '' ? null : secret };
|
|
135
|
+
}
|