@ultimat3/auth 1.1.0 → 2.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 +231 -0
- package/README.md +416 -34
- package/package.json +5 -4
- package/src/adapter.ts +69 -4
- package/src/auth.ts +96 -14
- package/src/builtin-adapter.ts +83 -6
- package/src/directory.ts +77 -0
- package/src/email.ts +17 -0
- package/src/errors.ts +219 -14
- package/src/guards.ts +6 -26
- package/src/id-token.ts +48 -26
- package/src/index.ts +105 -13
- package/src/json.ts +33 -0
- package/src/jwks.ts +246 -0
- package/src/kdf-gate.ts +86 -0
- package/src/memory-adapter.ts +66 -4
- package/src/oauth-builtins.ts +77 -0
- package/src/oauth-cookie.ts +4 -3
- package/src/oauth-discovery.ts +132 -0
- package/src/oauth-exchange.ts +40 -18
- package/src/oauth-login-fixture.ts +53 -0
- package/src/oauth-login.ts +111 -14
- package/src/oauth-paths.ts +20 -0
- package/src/oauth-profile.ts +9 -10
- package/src/oauth-registry.ts +65 -0
- package/src/oauth-route.ts +293 -0
- package/src/oauth.ts +31 -58
- package/src/password.ts +20 -8
- package/src/policy-bridge.ts +11 -5
- package/src/privileges.ts +74 -0
- package/src/rate-limit.ts +178 -15
- package/src/revocation.ts +100 -0
- package/src/session.ts +33 -4
- package/src/tables.ts +18 -2
- package/src/tokens.ts +26 -17
- package/src/verify.ts +12 -5
- package/src/workload.ts +131 -0
package/src/session.ts
CHANGED
|
@@ -12,13 +12,34 @@ import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
|
|
|
12
12
|
export interface SessionPolicy {
|
|
13
13
|
/** Hard ceiling from creation. Never extended. */
|
|
14
14
|
readonly absoluteTtlMs: number;
|
|
15
|
-
/** Measured from `lastSeenAt`, refreshed
|
|
15
|
+
/** Measured from `lastSeenAt`, refreshed at most once per `idleSlideMs`. */
|
|
16
16
|
readonly idleTtlMs: number;
|
|
17
|
+
/**
|
|
18
|
+
* How stale `lastSeenAt` may get before a verified request writes it forward. Absent means
|
|
19
|
+
* `idleTtlMs / IDLE_SLIDE_DIVISOR`, derived rather than a constant so an app that shortens
|
|
20
|
+
* `idleTtlMs` does not silently get a slide longer than its own idle window — which would
|
|
21
|
+
* pin every session to its creation time and expire it on the dot.
|
|
22
|
+
*
|
|
23
|
+
* It exists because `verifySession` used to write on EVERY authenticated request: one request
|
|
24
|
+
* was a SELECT, an `UPDATE … RETURNING *` and a second SELECT, before the app's own first
|
|
25
|
+
* query. At 20k rps that is 20k writes a second on one hot table, autovacuum falls behind, and
|
|
26
|
+
* the incident reads as "the database is slow" rather than "authentication is a write path".
|
|
27
|
+
* The trade is bounded: idle expiry is now precise to within one `idleSlideMs`.
|
|
28
|
+
*/
|
|
29
|
+
readonly idleSlideMs?: number | undefined;
|
|
17
30
|
readonly cookieName: string;
|
|
18
31
|
/** Mint a new session id whenever roles/scopes change — closes session fixation. */
|
|
19
32
|
readonly rotateOnPrivilegeChange: boolean;
|
|
20
33
|
}
|
|
21
34
|
|
|
35
|
+
/** 20 → roughly 5% of the idle window, so the write rate falls ~20× and the drift stays small. */
|
|
36
|
+
export const IDLE_SLIDE_DIVISOR = 20;
|
|
37
|
+
|
|
38
|
+
/** The resolved slide, wherever it is read. One derivation, so the two readers cannot disagree. */
|
|
39
|
+
export function idleSlideMs(policy: SessionPolicy): number {
|
|
40
|
+
return Math.max(0, policy.idleSlideMs ?? Math.floor(policy.idleTtlMs / IDLE_SLIDE_DIVISOR));
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
export const DEFAULT_SESSION_POLICY: SessionPolicy = Object.freeze({
|
|
23
44
|
absoluteTtlMs: 30 * 24 * 60 * 60 * 1000,
|
|
24
45
|
idleTtlMs: 7 * 24 * 60 * 60 * 1000,
|
|
@@ -134,10 +155,18 @@ export async function verifySession(
|
|
|
134
155
|
throw sessionExpired(expiry.absoluteExpired ? 'absolute' : 'idle', session.id);
|
|
135
156
|
}
|
|
136
157
|
|
|
158
|
+
const ip = observed?.ip ?? session.ip;
|
|
159
|
+
const userAgent = observed?.userAgent ?? session.userAgent;
|
|
160
|
+
// The window slides only when it has actually moved. A second request inside the slide issues
|
|
161
|
+
// no write at all — the read path stays a read — while a changed address or user agent is
|
|
162
|
+
// written immediately, because that is the row a device list and an incident review read.
|
|
163
|
+
const stale = now.getTime() - session.lastSeenAt.getTime() >= idleSlideMs(runtime.policy);
|
|
164
|
+
if (!stale && ip === session.ip && userAgent === session.userAgent) return session;
|
|
165
|
+
|
|
137
166
|
const touched = await runtime.store.updateSession(session.id, {
|
|
138
|
-
lastSeenAt: now,
|
|
139
|
-
ip
|
|
140
|
-
userAgent
|
|
167
|
+
lastSeenAt: stale ? now : session.lastSeenAt,
|
|
168
|
+
ip,
|
|
169
|
+
userAgent,
|
|
141
170
|
});
|
|
142
171
|
return touched ?? session;
|
|
143
172
|
}
|
package/src/tables.ts
CHANGED
|
@@ -10,11 +10,26 @@ export const X_USERS_TABLE = `create table if not exists x_users (
|
|
|
10
10
|
org_id uuid,
|
|
11
11
|
roles text[] not null default '{}',
|
|
12
12
|
permissions text[] not null default '{}',
|
|
13
|
+
scopes text[] not null default '{}',
|
|
14
|
+
external_id text unique,
|
|
13
15
|
mfa_secret text,
|
|
14
16
|
recovery_code_hashes text[] not null default '{}',
|
|
15
17
|
disabled_at timestamptz,
|
|
16
18
|
created_at timestamptz not null default now()
|
|
17
|
-
)
|
|
19
|
+
);
|
|
20
|
+
create index if not exists x_users_org_id_idx on x_users (org_id)`;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The two columns `x_users` gained in 1.3.0, as the statements an app already running 1.2 runs
|
|
24
|
+
* once. Both are additive and both have a default, so the migration is not a rewrite and takes no
|
|
25
|
+
* exclusive lock beyond the catalog update.
|
|
26
|
+
*/
|
|
27
|
+
export const X_USERS_MIGRATION_1_3: readonly string[] = Object.freeze([
|
|
28
|
+
`alter table x_users add column if not exists scopes text[] not null default '{}'`,
|
|
29
|
+
'alter table x_users add column if not exists external_id text',
|
|
30
|
+
'create unique index if not exists x_users_external_id_key on x_users (external_id)',
|
|
31
|
+
'create index if not exists x_users_org_id_idx on x_users (org_id)',
|
|
32
|
+
]);
|
|
18
33
|
|
|
19
34
|
// `id` is the public half of the cookie; `token_hash` is sha256 of the secret half.
|
|
20
35
|
export const X_SESSIONS_TABLE = `create table if not exists x_sessions (
|
|
@@ -29,7 +44,8 @@ export const X_SESSIONS_TABLE = `create table if not exists x_sessions (
|
|
|
29
44
|
mfa_satisfied boolean not null default false
|
|
30
45
|
);
|
|
31
46
|
create index if not exists x_sessions_user_id_idx on x_sessions (user_id);
|
|
32
|
-
create index if not exists x_sessions_absolute_expires_at_idx on x_sessions (absolute_expires_at)
|
|
47
|
+
create index if not exists x_sessions_absolute_expires_at_idx on x_sessions (absolute_expires_at);
|
|
48
|
+
create index if not exists x_sessions_created_at_idx on x_sessions (created_at)`;
|
|
33
49
|
|
|
34
50
|
export const X_ACCOUNTS_TABLE = `create table if not exists x_accounts (
|
|
35
51
|
id uuid primary key,
|
package/src/tokens.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// Single responsibility: the secret primitives every other file in this package shares —
|
|
2
|
-
// CSPRNG tokens
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// CSPRNG tokens and SHA-256 hashing. Centralised so no call site can quietly reach for `===` on
|
|
3
|
+
// a secret, which leaks the shared prefix length one request at a time. The constant-time
|
|
4
|
+
// comparison itself lives in `@ultimat3/core` (`timingSafeEqual`) — `@ultimat3/storage` needs the
|
|
5
|
+
// exact same one, and re-exporting it here keeps every existing `from '@ultimat3/auth'` import
|
|
6
|
+
// working.
|
|
7
|
+
|
|
8
|
+
import { timingSafeEqual } from '@ultimat3/core';
|
|
9
|
+
|
|
10
|
+
export { timingSafeEqual };
|
|
5
11
|
|
|
6
12
|
const BASE64URL_UNSAFE = /[+/=]/g;
|
|
7
13
|
const BASE64URL_REPLACEMENTS: Readonly<Record<string, string>> = { '+': '-', '/': '_', '=': '' };
|
|
@@ -23,6 +29,23 @@ export function randomToken(byteLength = 32): string {
|
|
|
23
29
|
return base64Url(randomBytes(byteLength));
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The inverse of `base64Url`, for the segments of a JWT. Answers `null` rather than throwing
|
|
34
|
+
* because every caller is reading an attacker-supplied string — a `URIError` or an `InvalidCharacterError`
|
|
35
|
+
* escaping from here would turn a coded refusal into a 500, the same reason `readCookie` never throws.
|
|
36
|
+
*/
|
|
37
|
+
export function base64UrlBytes(segment: string): Uint8Array<ArrayBuffer> | null {
|
|
38
|
+
const padded = segment
|
|
39
|
+
.replaceAll('-', '+')
|
|
40
|
+
.replaceAll('_', '/')
|
|
41
|
+
.padEnd(Math.ceil(segment.length / 4) * 4, '=');
|
|
42
|
+
try {
|
|
43
|
+
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
export function sha256Hex(value: string): string {
|
|
27
50
|
return new Bun.CryptoHasher('sha256').update(value).digest('hex');
|
|
28
51
|
}
|
|
@@ -31,20 +54,6 @@ export function sha256Bytes(value: string): Uint8Array {
|
|
|
31
54
|
return Uint8Array.from(new Bun.CryptoHasher('sha256').update(value).digest());
|
|
32
55
|
}
|
|
33
56
|
|
|
34
|
-
/**
|
|
35
|
-
* Length is compared first and non-constant-time on purpose: every secret this package
|
|
36
|
-
* compares is a fixed-width hash or token, so the length carries no information, and the
|
|
37
|
-
* XOR accumulator below is what has to be branch-free.
|
|
38
|
-
*/
|
|
39
|
-
export function timingSafeEqual(a: string, b: string): boolean {
|
|
40
|
-
if (a.length !== b.length) return false;
|
|
41
|
-
let diff = 0;
|
|
42
|
-
for (let index = 0; index < a.length; index += 1) {
|
|
43
|
-
diff |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
44
|
-
}
|
|
45
|
-
return diff === 0;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
57
|
/** Hash-then-compare. The plaintext never has to be held next to the stored value. */
|
|
49
58
|
export function matchesHash(plaintext: string, storedHash: string): boolean {
|
|
50
59
|
return timingSafeEqual(sha256Hex(plaintext), storedHash);
|
package/src/verify.ts
CHANGED
|
@@ -108,19 +108,26 @@ export interface ConsumeVerificationInput {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
|
-
* The
|
|
112
|
-
*
|
|
111
|
+
* The hash goes INTO the consume, it is not compared after one: the store consumes the row only
|
|
112
|
+
* when the hash is the live row's, atomically, so a second redemption finds nothing even if it
|
|
113
|
+
* races the first *and* a wrong guess leaves the row live. Comparing afterwards made an
|
|
114
|
+
* unauthenticated POST with any token at all destroy the victim's emailed link — one request per
|
|
115
|
+
* address for permanent password-reset denial.
|
|
113
116
|
*/
|
|
114
117
|
export async function consumeVerification(
|
|
115
118
|
runtime: VerificationRuntime,
|
|
116
119
|
input: ConsumeVerificationInput,
|
|
117
120
|
): Promise<AuthVerification> {
|
|
118
|
-
const
|
|
121
|
+
const tokenHash = sha256Hex(input.token);
|
|
122
|
+
const record = await runtime.store.takeVerification(input.purpose, input.identifier, tokenHash);
|
|
119
123
|
if (record === null) throw verificationInvalid(input.purpose);
|
|
120
|
-
|
|
124
|
+
// Kept for the seam, not for the blessed adapters: `VerificationStore` is implementable by an
|
|
125
|
+
// app, and one that ignores the third argument would otherwise redeem any token. Constant-time
|
|
126
|
+
// because this package never compares a secret — or a digest of one — with `===`.
|
|
127
|
+
if (!timingSafeEqual(tokenHash, record.tokenHash)) {
|
|
121
128
|
throw verificationInvalid(input.purpose);
|
|
122
129
|
}
|
|
123
|
-
if (
|
|
130
|
+
if (runtime.clock.now().getTime() >= record.expiresAt.getTime()) {
|
|
124
131
|
throw verificationInvalid(input.purpose);
|
|
125
132
|
}
|
|
126
133
|
return record;
|
package/src/workload.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Single responsibility: verifying a workload's own JWT and turning it into a `ServiceIdentity`.
|
|
2
|
+
// Before this, `ServiceIdentity` was a plain struct with no non-test caller and nothing in the
|
|
3
|
+
// framework verified a service credential at all — no workload identity, no `client_credentials`,
|
|
4
|
+
// no token exchange — so two Ultimate services could only trust each other through a long-lived
|
|
5
|
+
// shared secret in an env var, with no rotation and no per-caller identity.
|
|
6
|
+
//
|
|
7
|
+
// One function covers the three shapes in practice, because they are all the same JWT: a
|
|
8
|
+
// Kubernetes projected service-account token, a SPIFFE JWT-SVID, and a cloud IMDS token. It is
|
|
9
|
+
// also the shape RFC 8693's `subject_token` takes, so a token-exchange endpoint reads through it.
|
|
10
|
+
//
|
|
11
|
+
// mTLS is deliberately out of scope: TLS termination is the mesh's job (axiom 7), and the
|
|
12
|
+
// framework's part is reading a trusted `x-forwarded-client-cert` through `@ultimat3/http`'s
|
|
13
|
+
// trusted-proxy seam — which is that package's to own, not this one's.
|
|
14
|
+
|
|
15
|
+
import type { Clock } from '@ultimat3/core';
|
|
16
|
+
import { AuthError } from './errors';
|
|
17
|
+
import { ID_TOKEN_CLOCK_SKEW_MS } from './id-token';
|
|
18
|
+
import { decodeJwtSegment } from './json';
|
|
19
|
+
import { type JwksKeySource, verifyJwtSignature } from './jwks';
|
|
20
|
+
import type { ServiceIdentity } from './policy-bridge';
|
|
21
|
+
|
|
22
|
+
/** The claims a workload token is read for. Everything else the issuer sends is ignored. */
|
|
23
|
+
export interface WorkloadClaims {
|
|
24
|
+
readonly iss: string;
|
|
25
|
+
readonly sub: string;
|
|
26
|
+
readonly aud: readonly string[];
|
|
27
|
+
readonly exp: number;
|
|
28
|
+
readonly nbf?: number | undefined;
|
|
29
|
+
readonly iat?: number | undefined;
|
|
30
|
+
/** `scope` (space-delimited, RFC 8693 / OAuth) or `scp` (an array). Absent means none. */
|
|
31
|
+
readonly scopes: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WorkloadToken {
|
|
35
|
+
readonly claims: WorkloadClaims;
|
|
36
|
+
/** Feed this to `actorFromService()` — the same funnel every other credential goes through. */
|
|
37
|
+
readonly identity: ServiceIdentity;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface VerifyWorkloadTokenInput {
|
|
41
|
+
readonly token: string;
|
|
42
|
+
/** Every issuer this caller may claim. A list, never a wildcard: an unpinned `iss` is no check. */
|
|
43
|
+
readonly issuers: readonly string[];
|
|
44
|
+
/** This service's own audience. A token addressed elsewhere is a token being replayed here. */
|
|
45
|
+
readonly audience: string;
|
|
46
|
+
/** Required. There is no trusted-channel exemption for a token that arrived in a header. */
|
|
47
|
+
readonly keys: JwksKeySource;
|
|
48
|
+
readonly clock: Clock;
|
|
49
|
+
/** Optional tenant, when the deployment gives a workload one. Never guessed from the token. */
|
|
50
|
+
readonly orgId?: string | null | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const refused = (reason: string): AuthError =>
|
|
54
|
+
new AuthError({
|
|
55
|
+
code: 'X_UNAUTHENTICATED',
|
|
56
|
+
cause: `the presented workload token was refused: ${reason}`,
|
|
57
|
+
// A service credential names no human, so a precise cause enumerates nothing — and the
|
|
58
|
+
// reader of this line is an operator holding a misconfigured deployment, not an attacker.
|
|
59
|
+
fix: "confirm the workload token's issuer, audience and key set match verifyWorkloadToken({ issuers, audience, keys })",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const readScopes = (payload: Record<string, unknown>): readonly string[] => {
|
|
63
|
+
const scope = payload['scope'];
|
|
64
|
+
if (typeof scope === 'string') return scope.split(' ').filter((one) => one !== '');
|
|
65
|
+
const scp = payload['scp'];
|
|
66
|
+
return Array.isArray(scp) ? scp.filter((one): one is string => typeof one === 'string') : [];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const readAudience = (value: unknown): readonly string[] => {
|
|
70
|
+
if (typeof value === 'string') return [value];
|
|
71
|
+
return Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : [];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Signature first, then issuer, audience and the two time bounds. The order matters for the same
|
|
76
|
+
* reason it does in `verifyIdToken`: a claim is only worth reading once something proved who
|
|
77
|
+
* wrote it, and every check below would otherwise pass for a token the caller minted themselves.
|
|
78
|
+
*/
|
|
79
|
+
export async function verifyWorkloadToken(input: VerifyWorkloadTokenInput): Promise<WorkloadToken> {
|
|
80
|
+
if (!(await verifyJwtSignature(input.token, input.keys))) {
|
|
81
|
+
throw refused('the signature does not verify against the published key set');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const payloadSegment = input.token.split('.')[1];
|
|
85
|
+
const parsed = payloadSegment === undefined ? null : decodeJwtSegment(payloadSegment);
|
|
86
|
+
if (parsed === null) {
|
|
87
|
+
throw refused('the payload is not base64url-encoded JSON describing an object');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const iss = parsed['iss'];
|
|
91
|
+
const sub = parsed['sub'];
|
|
92
|
+
const exp = parsed['exp'];
|
|
93
|
+
if (typeof iss !== 'string' || typeof sub !== 'string' || sub === '') {
|
|
94
|
+
throw refused('the payload carries no iss or sub');
|
|
95
|
+
}
|
|
96
|
+
if (typeof exp !== 'number') throw refused('the payload carries no numeric exp');
|
|
97
|
+
if (!input.issuers.includes(iss)) throw refused('the issuer is not one this service accepts');
|
|
98
|
+
|
|
99
|
+
const aud = readAudience(parsed['aud']);
|
|
100
|
+
if (!aud.includes(input.audience)) throw refused('the token is addressed to another audience');
|
|
101
|
+
|
|
102
|
+
const nowMs = input.clock.now().getTime();
|
|
103
|
+
// The same skew the id token path allows, from the same declaration: two servers rarely agree
|
|
104
|
+
// on the second, and a second number here would drift from that one.
|
|
105
|
+
if (exp * 1000 + ID_TOKEN_CLOCK_SKEW_MS <= nowMs) throw refused('the token is already expired');
|
|
106
|
+
const nbf = parsed['nbf'];
|
|
107
|
+
if (typeof nbf === 'number' && nbf * 1000 - ID_TOKEN_CLOCK_SKEW_MS > nowMs) {
|
|
108
|
+
throw refused('the token is not valid yet');
|
|
109
|
+
}
|
|
110
|
+
const iat = parsed['iat'];
|
|
111
|
+
|
|
112
|
+
const claims: WorkloadClaims = {
|
|
113
|
+
iss,
|
|
114
|
+
sub,
|
|
115
|
+
aud,
|
|
116
|
+
exp,
|
|
117
|
+
scopes: readScopes(parsed),
|
|
118
|
+
...(typeof nbf === 'number' ? { nbf } : {}),
|
|
119
|
+
...(typeof iat === 'number' ? { iat } : {}),
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
claims,
|
|
123
|
+
identity: {
|
|
124
|
+
// The SPIFFE ID, the service account's `system:serviceaccount:ns:name`, or the IMDS
|
|
125
|
+
// principal — whichever the issuer put there. Never rewritten, so a trace names the caller.
|
|
126
|
+
id: claims.sub,
|
|
127
|
+
scopes: claims.scopes,
|
|
128
|
+
...(input.orgId === undefined ? {} : { orgId: input.orgId }),
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|