@ultimat3/auth 1.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/src/oauth.ts ADDED
@@ -0,0 +1,168 @@
1
+ // Single responsibility: the OAuth2/OIDC handshake. PKCE is mandatory rather than
2
+ // provider-dependent — an authorization code with no proof-of-possession is stealable from a
3
+ // redirect, and "this provider does not need it" is how that becomes a real incident. Provider
4
+ // configs are pure data: importing this file performs no network I/O and reads no env.
5
+
6
+ import { oauthStateInvalid } from './errors';
7
+ import { base64Url, randomToken, sha256Bytes, timingSafeEqual } from './tokens';
8
+
9
+ export interface OAuthProvider {
10
+ readonly id: string;
11
+ readonly authorizeUrl: string;
12
+ readonly tokenUrl: string;
13
+ readonly userInfoUrl: string | null;
14
+ /** A second call, only where the primary address is not on the profile (GitHub). */
15
+ readonly userEmailsUrl: string | null;
16
+ /**
17
+ * Every `iss` this provider is allowed to claim. Empty for a provider that issues no id
18
+ * token. A list rather than one string because Google has issued both forms for years.
19
+ */
20
+ readonly issuers: readonly string[];
21
+ readonly scopes: readonly string[];
22
+ readonly usesPkce: boolean;
23
+ /** OIDC providers echo `nonce` in the id token; it binds the token to this browser. */
24
+ readonly usesNonce: boolean;
25
+ readonly clientIdEnv: string;
26
+ readonly clientSecretEnv: string;
27
+ }
28
+
29
+ export const OAUTH_PROVIDERS = {
30
+ github: {
31
+ id: 'github',
32
+ authorizeUrl: 'https://github.com/login/oauth/authorize',
33
+ tokenUrl: 'https://github.com/login/oauth/access_token',
34
+ userInfoUrl: 'https://api.github.com/user',
35
+ // GitHub omits a private address from the profile; the identity is still incomplete
36
+ // without it, so the flow asks for the verified list rather than guessing.
37
+ userEmailsUrl: 'https://api.github.com/user/emails',
38
+ issuers: [],
39
+ scopes: ['read:user', 'user:email'],
40
+ usesPkce: true,
41
+ usesNonce: false,
42
+ clientIdEnv: 'GITHUB_CLIENT_ID',
43
+ clientSecretEnv: 'GITHUB_CLIENT_SECRET',
44
+ },
45
+ google: {
46
+ id: 'google',
47
+ authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
48
+ tokenUrl: 'https://oauth2.googleapis.com/token',
49
+ // Reached only when a narrowed `scopes` leaves the id token without an email claim.
50
+ userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
51
+ userEmailsUrl: null,
52
+ issuers: ['https://accounts.google.com', 'accounts.google.com'],
53
+ scopes: ['openid', 'email', 'profile'],
54
+ usesPkce: true,
55
+ usesNonce: true,
56
+ clientIdEnv: 'GOOGLE_CLIENT_ID',
57
+ clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
58
+ },
59
+ apple: {
60
+ id: 'apple',
61
+ authorizeUrl: 'https://appleid.apple.com/auth/authorize',
62
+ tokenUrl: 'https://appleid.apple.com/auth/token',
63
+ // Apple returns claims in the id token only; there is no userinfo endpoint to call.
64
+ userInfoUrl: null,
65
+ userEmailsUrl: null,
66
+ issuers: ['https://appleid.apple.com'],
67
+ scopes: ['name', 'email'],
68
+ usesPkce: true,
69
+ usesNonce: true,
70
+ clientIdEnv: 'APPLE_CLIENT_ID',
71
+ // Apple alone does not accept a static secret: `APPLE_CLIENT_SECRET` must hold the ES256
72
+ // client-secret JWT signed with the .p8 key, which Apple expires every six months.
73
+ clientSecretEnv: 'APPLE_CLIENT_SECRET',
74
+ },
75
+ } as const satisfies Readonly<Record<string, OAuthProvider>>;
76
+
77
+ export type OAuthProviderId = keyof typeof OAUTH_PROVIDERS;
78
+
79
+ export const OAUTH_PROVIDER_IDS: readonly OAuthProviderId[] = Object.freeze(
80
+ Object.keys(OAUTH_PROVIDERS) as OAuthProviderId[],
81
+ );
82
+
83
+ export interface PkcePair {
84
+ readonly verifier: string;
85
+ readonly challenge: string;
86
+ readonly method: 'S256';
87
+ }
88
+
89
+ /** RFC 7636 S256: `BASE64URL(SHA256(ASCII(verifier)))`. `plain` is not offered, ever. */
90
+ export function pkceChallenge(verifier: string): string {
91
+ return base64Url(sha256Bytes(verifier));
92
+ }
93
+
94
+ export function createPkce(): PkcePair {
95
+ // 32 random bytes -> 43 base64url chars, the RFC's minimum verifier length.
96
+ const verifier = randomToken(32);
97
+ return { verifier, challenge: pkceChallenge(verifier), method: 'S256' };
98
+ }
99
+
100
+ /** Everything the server must remember between the redirect and the callback. */
101
+ export interface OAuthHandshake {
102
+ readonly provider: OAuthProviderId;
103
+ readonly state: string;
104
+ readonly nonce: string;
105
+ readonly verifier: string;
106
+ readonly redirectUri: string;
107
+ readonly authorizeUrl: string;
108
+ }
109
+
110
+ export interface BeginOAuthInput {
111
+ readonly provider: OAuthProviderId;
112
+ readonly clientId: string;
113
+ readonly redirectUri: string;
114
+ readonly scopes?: readonly string[] | undefined;
115
+ }
116
+
117
+ export function beginOAuth(input: BeginOAuthInput): OAuthHandshake {
118
+ const provider = OAUTH_PROVIDERS[input.provider];
119
+ const pkce = createPkce();
120
+ const state = randomToken(16);
121
+ const nonce = randomToken(16);
122
+ const url = new URL(provider.authorizeUrl);
123
+ url.searchParams.set('response_type', 'code');
124
+ url.searchParams.set('client_id', input.clientId);
125
+ url.searchParams.set('redirect_uri', input.redirectUri);
126
+ url.searchParams.set('scope', (input.scopes ?? provider.scopes).join(' '));
127
+ url.searchParams.set('state', state);
128
+ url.searchParams.set('code_challenge', pkce.challenge);
129
+ url.searchParams.set('code_challenge_method', pkce.method);
130
+ if (provider.usesNonce) url.searchParams.set('nonce', nonce);
131
+ return {
132
+ provider: input.provider,
133
+ state,
134
+ nonce,
135
+ verifier: pkce.verifier,
136
+ redirectUri: input.redirectUri,
137
+ authorizeUrl: url.toString(),
138
+ };
139
+ }
140
+
141
+ export interface OAuthCallback {
142
+ readonly state: string;
143
+ readonly code: string;
144
+ /**
145
+ * Only a `form_post` response carries a nonce back on the redirect itself. In the plain code
146
+ * flow the nonce is a claim inside the id token, and `verifyIdToken` is what checks it — so
147
+ * this is verified when present and never required, or an OIDC login could not complete.
148
+ */
149
+ readonly nonce?: string | undefined;
150
+ }
151
+
152
+ /**
153
+ * The only gate between a redirect and a token exchange. Every rejection is
154
+ * `X_OAUTH_STATE_INVALID` — the callback is one handshake, and naming which half failed
155
+ * tells an attacker which half to keep guessing at.
156
+ */
157
+ export function assertOAuthCallback(handshake: OAuthHandshake, callback: OAuthCallback): void {
158
+ const provider = OAUTH_PROVIDERS[handshake.provider];
159
+ if (!timingSafeEqual(handshake.state, callback.state)) {
160
+ throw oauthStateInvalid(provider.id, 'state did not match the stored handshake');
161
+ }
162
+ if (provider.usesPkce && handshake.verifier.length < 43) {
163
+ throw oauthStateInvalid(provider.id, 'no PKCE verifier was stored for this handshake');
164
+ }
165
+ if (callback.nonce !== undefined && !timingSafeEqual(handshake.nonce, callback.nonce)) {
166
+ throw oauthStateInvalid(provider.id, 'nonce did not match the stored handshake');
167
+ }
168
+ }
@@ -0,0 +1,136 @@
1
+ // Single responsibility: password hashing, verification and strength. Parameters are explicit
2
+ // and stored inside the PHC string, so raising them later is a rehash-on-next-login instead of
3
+ // a migration. Verification always burns a full KDF even when the user does not exist —
4
+ // otherwise response time answers "is this email registered?" for free.
5
+
6
+ import { passwordWeak } from './errors';
7
+
8
+ export interface PasswordParams {
9
+ readonly algorithm: 'argon2id';
10
+ /** KiB. OWASP's 2024 floor for argon2id at t=2, p=1. */
11
+ readonly memoryCost: number;
12
+ readonly timeCost: number;
13
+ }
14
+
15
+ export const DEFAULT_PASSWORD_PARAMS: PasswordParams = Object.freeze({
16
+ algorithm: 'argon2id',
17
+ memoryCost: 19_456,
18
+ timeCost: 2,
19
+ });
20
+
21
+ export interface PasswordPolicy {
22
+ readonly minLength: number;
23
+ readonly params: PasswordParams;
24
+ }
25
+
26
+ export const DEFAULT_PASSWORD_POLICY: PasswordPolicy = Object.freeze({
27
+ minLength: 12,
28
+ params: DEFAULT_PASSWORD_PARAMS,
29
+ });
30
+
31
+ export interface PasswordVerification {
32
+ readonly ok: boolean;
33
+ /** True when the stored hash used weaker parameters than the current policy. */
34
+ readonly needsRehash: boolean;
35
+ }
36
+
37
+ /** The one shape a failed verification takes. Identical for a wrong password and no user. */
38
+ const FAILED: PasswordVerification = Object.freeze({ ok: false, needsRehash: false });
39
+
40
+ const PHC_RE = /^\$argon2(id|i|d)\$v=\d+\$m=(\d+),t=(\d+)/;
41
+
42
+ /**
43
+ * Passwords that survive any length rule but are the first thing a credential-stuffing list
44
+ * tries. Deliberately tiny: a real deployment layers a breach corpus on top via `extraDenyList`.
45
+ */
46
+ const COMMON_PASSWORDS: ReadonlySet<string> = new Set([
47
+ 'password',
48
+ 'password1',
49
+ 'password123',
50
+ 'qwertyuiop',
51
+ '1234567890',
52
+ '123456789012',
53
+ 'letmein12345',
54
+ 'iloveyou1234',
55
+ 'administrator',
56
+ 'welcome12345',
57
+ 'correcthorse',
58
+ ]);
59
+
60
+ export async function hashPassword(
61
+ password: string,
62
+ params: PasswordParams = DEFAULT_PASSWORD_PARAMS,
63
+ ): Promise<string> {
64
+ return await Bun.password.hash(password, {
65
+ algorithm: params.algorithm,
66
+ memoryCost: params.memoryCost,
67
+ timeCost: params.timeCost,
68
+ });
69
+ }
70
+
71
+ /** Reads the parameters back out of a PHC string. `null` means "not a hash we recognise". */
72
+ export function parseHashParams(hash: string): PasswordParams | null {
73
+ const match = PHC_RE.exec(hash);
74
+ if (match === null) return null;
75
+ const variant = match[1];
76
+ const memoryCost = Number.parseInt(match[2] ?? '', 10);
77
+ const timeCost = Number.parseInt(match[3] ?? '', 10);
78
+ if (variant !== 'id' || !Number.isFinite(memoryCost) || !Number.isFinite(timeCost)) return null;
79
+ return { algorithm: 'argon2id', memoryCost, timeCost };
80
+ }
81
+
82
+ /** An unreadable hash, a different algorithm or weaker cost all mean "rehash on next login". */
83
+ export function needsRehash(
84
+ hash: string,
85
+ params: PasswordParams = DEFAULT_PASSWORD_PARAMS,
86
+ ): boolean {
87
+ const stored = parseHashParams(hash);
88
+ if (stored === null) return true;
89
+ return stored.memoryCost < params.memoryCost || stored.timeCost < params.timeCost;
90
+ }
91
+
92
+ export interface VerifyPasswordInput {
93
+ /** `null` when no user matched. The KDF still runs, on a throwaway hash. */
94
+ readonly hash: string | null;
95
+ readonly password: string;
96
+ readonly params?: PasswordParams | undefined;
97
+ }
98
+
99
+ /**
100
+ * Never short-circuits on a missing user: the `hashPassword` call in the `null` branch costs
101
+ * the same order of magnitude as the verify in the happy branch, so the two are not separable
102
+ * by a stopwatch. Callers must map `ok: false` to `loginFailed()` and nothing more specific.
103
+ */
104
+ export async function verifyPassword(input: VerifyPasswordInput): Promise<PasswordVerification> {
105
+ const params = input.params ?? DEFAULT_PASSWORD_PARAMS;
106
+ if (input.hash === null) {
107
+ await hashPassword(input.password, params);
108
+ return FAILED;
109
+ }
110
+ const ok = await Bun.password.verify(input.password, input.hash);
111
+ if (!ok) return FAILED;
112
+ return { ok: true, needsRehash: needsRehash(input.hash, params) };
113
+ }
114
+
115
+ export interface StrengthOptions {
116
+ readonly policy?: PasswordPolicy | undefined;
117
+ readonly extraDenyList?: ReadonlySet<string> | undefined;
118
+ }
119
+
120
+ /** Throws `X_PASSWORD_WEAK` listing every reason at once — one round trip, not a guessing game. */
121
+ export function checkPasswordStrength(password: string, options?: StrengthOptions): void {
122
+ const policy = options?.policy ?? DEFAULT_PASSWORD_POLICY;
123
+ const normalised = password.trim().toLowerCase();
124
+ const reasons: string[] = [];
125
+
126
+ if (password.length < policy.minLength) {
127
+ reasons.push(`it is ${password.length} characters, the policy requires ${policy.minLength}`);
128
+ }
129
+ if (COMMON_PASSWORDS.has(normalised) || options?.extraDenyList?.has(normalised) === true) {
130
+ reasons.push('it appears in the known-password deny list');
131
+ }
132
+ if (password.length > 0 && new Set(password).size <= 2) {
133
+ reasons.push('it uses two or fewer distinct characters');
134
+ }
135
+ if (reasons.length > 0) throw passwordWeak(reasons);
136
+ }
@@ -0,0 +1,105 @@
1
+ // Single responsibility: turn an authenticated identity into core's `Actor`. There is exactly
2
+ // one authz system in Ultimate — `@ultimat3/policy` — and auth's only job is producing the actor
3
+ // it evaluates. Nothing downstream ever authorizes on a session row, a user row or an api key.
4
+ // `PolicyActorFields` mirrors `@ultimat3/policy`'s shape structurally so this package does not
5
+ // import a same-tier package; policy binds to it by structure, in whichever order they land.
6
+
7
+ import type { Actor } from '@ultimat3/core';
8
+ import { agentActor, anonymousActor, assertNever, serviceActor, userActor } from '@ultimat3/core';
9
+ import type { AuthApiKeyRecord, AuthSession, AuthUser } from './adapter';
10
+
11
+ /** Structural mirror of `@ultimat3/policy`'s `PolicyActorFields`. Kept in sync by hand. */
12
+ export interface PolicyActorFields {
13
+ readonly id: string;
14
+ readonly roles?: readonly string[] | undefined;
15
+ /** Direct grants that bypass roles. Service tokens and break-glass accounts only. */
16
+ readonly permissions?: readonly string[] | undefined;
17
+ readonly orgId?: string | null | undefined;
18
+ }
19
+
20
+ export type PolicyActor = Actor & PolicyActorFields;
21
+
22
+ export interface ServiceIdentity {
23
+ readonly id: string;
24
+ readonly orgId?: string | null | undefined;
25
+ readonly scopes: readonly string[];
26
+ }
27
+
28
+ /** The four `ActorKind`s, as the four things that can be holding a credential. */
29
+ export type AuthIdentity =
30
+ | { readonly kind: 'user'; readonly user: AuthUser; readonly session: AuthSession }
31
+ | { readonly kind: 'agent'; readonly apiKey: AuthApiKeyRecord }
32
+ | { readonly kind: 'service'; readonly service: ServiceIdentity }
33
+ | { readonly kind: 'anonymous' };
34
+
35
+ const withPermissions = (actor: Actor, permissions: readonly string[]): PolicyActor => ({
36
+ ...actor,
37
+ permissions,
38
+ });
39
+
40
+ /**
41
+ * A human. Roles come from the row and are expanded to permissions by policy; scopes stay
42
+ * empty because a browser session is not scope-limited — the role set is the limit.
43
+ *
44
+ * A session that has not satisfied an enrolled second factor resolves to an actor with no
45
+ * roles and no permissions rather than an error, so a half-authenticated request can still
46
+ * reach the "finish MFA" route and nothing else. Login throws `X_MFA_REQUIRED` separately.
47
+ */
48
+ export function actorFromUser(user: AuthUser, session: AuthSession): PolicyActor {
49
+ const mfaPending = user.mfaSecret !== null && !session.mfaSatisfied;
50
+ return withPermissions(
51
+ userActor({
52
+ id: user.id,
53
+ orgId: user.orgId ?? undefined,
54
+ roles: mfaPending ? [] : user.roles,
55
+ scopes: [],
56
+ }),
57
+ mfaPending ? [] : user.permissions,
58
+ );
59
+ }
60
+
61
+ /**
62
+ * An MCP/LLM caller. The actor's scopes are **exactly** the key's scopes — never the owning
63
+ * user's roles, never a default set. An agent that can do more than its key says is the whole
64
+ * failure mode this bridge exists to prevent.
65
+ */
66
+ export function actorFromApiKey(key: AuthApiKeyRecord): PolicyActor {
67
+ return withPermissions(
68
+ agentActor({
69
+ id: key.id,
70
+ orgId: key.orgId ?? undefined,
71
+ roles: [],
72
+ scopes: key.scopes,
73
+ }),
74
+ key.scopes,
75
+ );
76
+ }
77
+
78
+ /** Machine-to-machine inside the deployment. Scopes are the grant; there are no roles. */
79
+ export function actorFromService(service: ServiceIdentity): PolicyActor {
80
+ return withPermissions(
81
+ serviceActor({
82
+ id: service.id,
83
+ orgId: service.orgId ?? undefined,
84
+ roles: [],
85
+ scopes: service.scopes,
86
+ }),
87
+ service.scopes,
88
+ );
89
+ }
90
+
91
+ /** The single funnel. Every surface resolves its caller through this and nothing else. */
92
+ export function resolveActor(identity: AuthIdentity): PolicyActor {
93
+ switch (identity.kind) {
94
+ case 'user':
95
+ return actorFromUser(identity.user, identity.session);
96
+ case 'agent':
97
+ return actorFromApiKey(identity.apiKey);
98
+ case 'service':
99
+ return actorFromService(identity.service);
100
+ case 'anonymous':
101
+ return anonymousActor();
102
+ default:
103
+ return assertNever(identity);
104
+ }
105
+ }
@@ -0,0 +1,104 @@
1
+ // Single responsibility: throttling and lockout for credential paths, plus the one generic
2
+ // failure every login path must throw. Two independent buckets — per IP (stops a spray across
3
+ // many accounts) and per account (stops a spray against one) — because either alone is
4
+ // bypassable. `loginFailed()` lives here so the throttle and the message can never drift apart.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import { AuthError, accountLocked } from './errors';
8
+
9
+ export interface AuthRateLimitPolicy {
10
+ /** Failures inside `windowMs` before the key is locked. */
11
+ readonly maxAttempts: number;
12
+ readonly windowMs: number;
13
+ readonly lockoutMs: number;
14
+ }
15
+
16
+ export const DEFAULT_AUTH_RATE_LIMIT: AuthRateLimitPolicy = Object.freeze({
17
+ maxAttempts: 5,
18
+ windowMs: 15 * 60 * 1000,
19
+ lockoutMs: 15 * 60 * 1000,
20
+ });
21
+
22
+ export interface AuthLimiter {
23
+ /** Throws `X_ACCOUNT_LOCKED` if the key is inside a lockout. Call before any KDF work. */
24
+ assertAllowed(key: string): void;
25
+ recordFailure(key: string): void;
26
+ /** A success clears the window: a legitimate user is not punished for a typo yesterday. */
27
+ recordSuccess(key: string): void;
28
+ lockedUntil(key: string): Date | null;
29
+ reset(): void;
30
+ }
31
+
32
+ export const accountKey = (email: string): string => `account:${email.trim().toLowerCase()}`;
33
+
34
+ export const ipKey = (ip: string): string => `ip:${ip}`;
35
+
36
+ interface Bucket {
37
+ failures: number[];
38
+ lockedUntilMs: number;
39
+ }
40
+
41
+ /**
42
+ * Sliding window over an injected `Clock` — never `Date.now()`, so a lockout test is
43
+ * deterministic instead of a sleep. In-memory per process; a multi-process deployment passes
44
+ * a shared implementation of the same interface.
45
+ */
46
+ export function createAuthLimiter(
47
+ clock: Clock,
48
+ policy: AuthRateLimitPolicy = DEFAULT_AUTH_RATE_LIMIT,
49
+ ): AuthLimiter {
50
+ const buckets = new Map<string, Bucket>();
51
+
52
+ const bucketFor = (key: string): Bucket => {
53
+ const existing = buckets.get(key);
54
+ if (existing !== undefined) return existing;
55
+ const fresh: Bucket = { failures: [], lockedUntilMs: 0 };
56
+ buckets.set(key, fresh);
57
+ return fresh;
58
+ };
59
+
60
+ return {
61
+ assertAllowed(key) {
62
+ const bucket = buckets.get(key);
63
+ if (bucket === undefined) return;
64
+ const nowMs = clock.now().getTime();
65
+ if (bucket.lockedUntilMs <= nowMs) return;
66
+ throw accountLocked(key, Math.ceil((bucket.lockedUntilMs - nowMs) / 1000));
67
+ },
68
+ recordFailure(key) {
69
+ const nowMs = clock.now().getTime();
70
+ const bucket = bucketFor(key);
71
+ bucket.failures = bucket.failures.filter((at) => at > nowMs - policy.windowMs);
72
+ bucket.failures.push(nowMs);
73
+ if (bucket.failures.length >= policy.maxAttempts) {
74
+ bucket.lockedUntilMs = nowMs + policy.lockoutMs;
75
+ }
76
+ },
77
+ recordSuccess(key) {
78
+ buckets.delete(key);
79
+ },
80
+ lockedUntil(key) {
81
+ const bucket = buckets.get(key);
82
+ if (bucket === undefined || bucket.lockedUntilMs <= clock.now().getTime()) return null;
83
+ return new Date(bucket.lockedUntilMs);
84
+ },
85
+ reset() {
86
+ buckets.clear();
87
+ },
88
+ };
89
+ }
90
+
91
+ /**
92
+ * The single generic failure. Every credential path — unknown email, wrong password, disabled
93
+ * account, unverified address — throws exactly this object shape, so the rendered error is
94
+ * byte-identical and account existence is unobservable. Do not add a parameter to it.
95
+ */
96
+ export function loginFailed(): AuthError {
97
+ return new AuthError({
98
+ code: 'X_UNAUTHENTICATED',
99
+ cause:
100
+ 'the email and password combination did not match an account — re-enter them before ' +
101
+ 'issuing the reset below, which mails a single-use token',
102
+ fix: "issueVerification(runtime, { purpose: 'password-reset', identifier: email, locale })",
103
+ });
104
+ }