@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/auth.ts ADDED
@@ -0,0 +1,233 @@
1
+ // Single responsibility: the one configuration entry point and the credential flows built on
2
+ // it. The output of authentication is an `Actor` from `@ultimat3/core` — every surface
3
+ // (http, actions, jobs, MCP) authorizes on that and never on a session row, so there is one
4
+ // identity shape in the framework and `@ultimat3/policy` is the only thing that reads it.
5
+
6
+ import { type Clock, systemClock, uuid } from '@ultimat3/core';
7
+ import { t } from '@ultimat3/schema';
8
+ import type { AuthAdapter, AuthSession, AuthUser } from './adapter';
9
+ import { mfaRequired, sessionUnknown } from './errors';
10
+ import type { OAuthProviderId } from './oauth';
11
+ import { OAUTH_PROVIDER_IDS } from './oauth';
12
+ import {
13
+ checkPasswordStrength,
14
+ DEFAULT_PASSWORD_POLICY,
15
+ hashPassword,
16
+ type PasswordPolicy,
17
+ verifyPassword,
18
+ } from './password';
19
+ import { type PolicyActor, resolveActor } from './policy-bridge';
20
+ import {
21
+ type AuthLimiter,
22
+ type AuthRateLimitPolicy,
23
+ accountKey,
24
+ createAuthLimiter,
25
+ DEFAULT_AUTH_RATE_LIMIT,
26
+ ipKey,
27
+ loginFailed,
28
+ } from './rate-limit';
29
+ import {
30
+ createSession,
31
+ DEFAULT_SESSION_POLICY,
32
+ parseSessionToken,
33
+ type SessionPolicy,
34
+ type SessionRuntime,
35
+ sessionCookie,
36
+ verifySession,
37
+ } from './session';
38
+
39
+ // The projections safe to hand to a client or an MCP tool: no password hash, no TOTP secret,
40
+ // no token hash. The private columns live in `adapter.ts` and never leave the server.
41
+ export const UserSchema = t.object({
42
+ id: t.uuid,
43
+ email: t.email,
44
+ emailVerifiedAt: t.optional(t.date),
45
+ orgId: t.optional(t.uuid),
46
+ roles: t.array(t.string),
47
+ permissions: t.array(t.string),
48
+ mfaEnrolled: t.boolean,
49
+ createdAt: t.date,
50
+ });
51
+
52
+ export const SessionSchema = t.object({
53
+ id: t.string,
54
+ userId: t.uuid,
55
+ createdAt: t.date,
56
+ absoluteExpiresAt: t.date,
57
+ lastSeenAt: t.date,
58
+ ip: t.optional(t.string),
59
+ userAgent: t.optional(t.string),
60
+ mfaSatisfied: t.boolean,
61
+ });
62
+
63
+ export const AccountSchema = t.object({
64
+ id: t.uuid,
65
+ userId: t.uuid,
66
+ provider: t.enum(['github', 'google', 'apple']),
67
+ providerAccountId: t.string,
68
+ expiresAt: t.optional(t.date),
69
+ createdAt: t.date,
70
+ });
71
+
72
+ export const VerificationSchema = t.object({
73
+ id: t.string,
74
+ purpose: t.enum(['email-verify', 'password-reset']),
75
+ identifier: t.email,
76
+ expiresAt: t.date,
77
+ consumedAt: t.optional(t.date),
78
+ createdAt: t.date,
79
+ });
80
+
81
+ export interface AuthMfaPolicy {
82
+ /** Shown in the authenticator app. Usually the product name. */
83
+ readonly issuer: string;
84
+ readonly required: boolean;
85
+ }
86
+
87
+ export interface AuthConfigInput {
88
+ readonly adapter: AuthAdapter;
89
+ readonly clock?: Clock | undefined;
90
+ readonly session?: Partial<SessionPolicy> | undefined;
91
+ readonly password?: Partial<PasswordPolicy> | undefined;
92
+ readonly rateLimit?: Partial<AuthRateLimitPolicy> | undefined;
93
+ readonly mfa?: Partial<AuthMfaPolicy> | undefined;
94
+ readonly providers?: readonly OAuthProviderId[] | undefined;
95
+ }
96
+
97
+ export interface Auth {
98
+ readonly adapter: AuthAdapter;
99
+ readonly clock: Clock;
100
+ readonly sessions: SessionRuntime;
101
+ readonly password: PasswordPolicy;
102
+ readonly rateLimit: AuthRateLimitPolicy;
103
+ readonly limiter: AuthLimiter;
104
+ readonly mfa: AuthMfaPolicy;
105
+ readonly providers: readonly OAuthProviderId[];
106
+ }
107
+
108
+ export function defineAuth(config: AuthConfigInput): Auth {
109
+ const clock = config.clock ?? systemClock;
110
+ const session: SessionPolicy = { ...DEFAULT_SESSION_POLICY, ...config.session };
111
+ const password: PasswordPolicy = { ...DEFAULT_PASSWORD_POLICY, ...config.password };
112
+ const rateLimit: AuthRateLimitPolicy = { ...DEFAULT_AUTH_RATE_LIMIT, ...config.rateLimit };
113
+ return Object.freeze({
114
+ adapter: config.adapter,
115
+ clock,
116
+ sessions: { store: config.adapter, policy: session, clock },
117
+ password,
118
+ rateLimit,
119
+ limiter: createAuthLimiter(clock, rateLimit),
120
+ mfa: { issuer: config.mfa?.issuer ?? 'Ultimate', required: config.mfa?.required ?? false },
121
+ providers: config.providers ?? OAUTH_PROVIDER_IDS,
122
+ });
123
+ }
124
+
125
+ export interface RegisterInput {
126
+ readonly email: string;
127
+ readonly password: string;
128
+ readonly orgId?: string | null | undefined;
129
+ readonly roles?: readonly string[] | undefined;
130
+ }
131
+
132
+ /** Strength is checked before the KDF runs — a weak password should not cost 100ms to reject. */
133
+ export async function register(auth: Auth, input: RegisterInput): Promise<AuthUser> {
134
+ checkPasswordStrength(input.password, { policy: auth.password });
135
+ return await auth.adapter.createUser({
136
+ id: uuid(auth.clock),
137
+ email: input.email.trim().toLowerCase(),
138
+ passwordHash: await hashPassword(input.password, auth.password.params),
139
+ orgId: input.orgId ?? null,
140
+ roles: input.roles ?? [],
141
+ createdAt: auth.clock.now(),
142
+ });
143
+ }
144
+
145
+ export interface LoginInput {
146
+ readonly email: string;
147
+ readonly password: string;
148
+ readonly ip?: string | null | undefined;
149
+ readonly userAgent?: string | null | undefined;
150
+ }
151
+
152
+ export interface LoginResult {
153
+ readonly actor: PolicyActor;
154
+ readonly session: AuthSession;
155
+ /** Shown once — put it in the cookie below and forget it. */
156
+ readonly token: string;
157
+ readonly cookie: string;
158
+ }
159
+
160
+ /**
161
+ * Every failure path here throws `loginFailed()` — unknown address, wrong password and
162
+ * disabled account are indistinguishable in both message and duration. The only paths that
163
+ * throw something else are lockout (before any work) and MFA (after the password is proven).
164
+ */
165
+ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult> {
166
+ const account = accountKey(input.email);
167
+ const ip = input.ip ?? null;
168
+ auth.limiter.assertAllowed(account);
169
+ if (ip !== null) auth.limiter.assertAllowed(ipKey(ip));
170
+
171
+ const user = await auth.adapter.findUserByEmail(input.email.trim().toLowerCase());
172
+ const usable = user !== null && user.disabledAt === null;
173
+ const verification = await verifyPassword({
174
+ hash: usable ? user.passwordHash : null,
175
+ password: input.password,
176
+ params: auth.password.params,
177
+ });
178
+
179
+ if (!verification.ok || user === null) {
180
+ auth.limiter.recordFailure(account);
181
+ if (ip !== null) auth.limiter.recordFailure(ipKey(ip));
182
+ throw loginFailed();
183
+ }
184
+
185
+ auth.limiter.recordSuccess(account);
186
+ if (ip !== null) auth.limiter.recordSuccess(ipKey(ip));
187
+
188
+ // Parameters were raised since this hash was written: upgrade it now, while we hold the
189
+ // plaintext. This is the only moment it is possible without asking the user for anything.
190
+ if (verification.needsRehash) {
191
+ await auth.adapter.updateUser(user.id, {
192
+ passwordHash: await hashPassword(input.password, auth.password.params),
193
+ });
194
+ }
195
+
196
+ // Password proven, second factor not. The client finishes at POST /auth/mfa/verify, which is
197
+ // what mints the session — no half-authenticated session is written here.
198
+ if (user.mfaSecret !== null) throw mfaRequired(user.id);
199
+
200
+ const issued = await createSession(auth.sessions, {
201
+ userId: user.id,
202
+ ip,
203
+ userAgent: input.userAgent,
204
+ mfaSatisfied: true,
205
+ });
206
+ return {
207
+ actor: resolveActor({ kind: 'user', user, session: issued.session }),
208
+ session: issued.session,
209
+ token: issued.token,
210
+ cookie: sessionCookie(issued.token, auth.sessions.policy),
211
+ };
212
+ }
213
+
214
+ /**
215
+ * The http auth stage. A missing cookie is anonymous, not an error — `meta.auth` decides
216
+ * whether anonymous is acceptable, and it does that in one place.
217
+ */
218
+ export async function authenticate(auth: Auth, token: string | null): Promise<PolicyActor> {
219
+ if (token === null || token.length === 0) return resolveActor({ kind: 'anonymous' });
220
+ const session = await verifySession(auth.sessions, token);
221
+ const user = await auth.adapter.findUserById(session.userId);
222
+ if (user === null || user.disabledAt !== null) {
223
+ await auth.adapter.deleteSession(session.id);
224
+ throw sessionUnknown();
225
+ }
226
+ return resolveActor({ kind: 'user', user, session });
227
+ }
228
+
229
+ export async function logout(auth: Auth, token: string): Promise<boolean> {
230
+ const parsed = parseSessionToken(token);
231
+ if (parsed === null) return false;
232
+ return await auth.adapter.deleteSession(parsed.id);
233
+ }
@@ -0,0 +1,286 @@
1
+ // Single responsibility: the one blessed `AuthAdapter`, backed by Postgres through
2
+ // `@ultimat3/db`. The `DbClient` is injected (defaulting to `db()`) so tests and the CLI can
3
+ // drive it without a database. Rows arrive as `unknown` and are read through the small typed
4
+ // readers below — no `any`, and a column rename fails loudly instead of producing `undefined`.
5
+
6
+ import { type DbClient, db, sql } from '@ultimat3/db';
7
+ import type {
8
+ AuthAccount,
9
+ AuthAdapter,
10
+ AuthApiKeyRecord,
11
+ AuthSession,
12
+ AuthUser,
13
+ AuthVerification,
14
+ CreateUserInput,
15
+ SessionPatch,
16
+ UserPatch,
17
+ } from './adapter';
18
+ import { authWriteFailed } from './errors';
19
+
20
+ type Row = Readonly<Record<string, unknown>>;
21
+
22
+ const textOrNull = (row: Row, key: string): string | null => {
23
+ const value = row[key];
24
+ return typeof value === 'string' ? value : null;
25
+ };
26
+
27
+ const text = (row: Row, key: string): string => textOrNull(row, key) ?? '';
28
+
29
+ const flag = (row: Row, key: string): boolean => row[key] === true;
30
+
31
+ const list = (row: Row, key: string): readonly string[] => {
32
+ const value = row[key];
33
+ if (!Array.isArray(value)) return [];
34
+ return value.filter((item): item is string => typeof item === 'string');
35
+ };
36
+
37
+ const dateOrNull = (row: Row, key: string): Date | null => {
38
+ const value = row[key];
39
+ if (value instanceof Date) return value;
40
+ if (typeof value === 'string' || typeof value === 'number') {
41
+ const parsed = new Date(value);
42
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
43
+ }
44
+ return null;
45
+ };
46
+
47
+ const date = (row: Row, key: string): Date => dateOrNull(row, key) ?? new Date(0);
48
+
49
+ const toUser = (row: Row): AuthUser => ({
50
+ id: text(row, 'id'),
51
+ email: text(row, 'email'),
52
+ emailVerifiedAt: dateOrNull(row, 'email_verified_at'),
53
+ passwordHash: textOrNull(row, 'password_hash'),
54
+ orgId: textOrNull(row, 'org_id'),
55
+ roles: list(row, 'roles'),
56
+ permissions: list(row, 'permissions'),
57
+ mfaSecret: textOrNull(row, 'mfa_secret'),
58
+ recoveryCodeHashes: list(row, 'recovery_code_hashes'),
59
+ disabledAt: dateOrNull(row, 'disabled_at'),
60
+ createdAt: date(row, 'created_at'),
61
+ });
62
+
63
+ const toSession = (row: Row): AuthSession => ({
64
+ id: text(row, 'id'),
65
+ userId: text(row, 'user_id'),
66
+ tokenHash: text(row, 'token_hash'),
67
+ createdAt: date(row, 'created_at'),
68
+ absoluteExpiresAt: date(row, 'absolute_expires_at'),
69
+ lastSeenAt: date(row, 'last_seen_at'),
70
+ ip: textOrNull(row, 'ip'),
71
+ userAgent: textOrNull(row, 'user_agent'),
72
+ mfaSatisfied: flag(row, 'mfa_satisfied'),
73
+ });
74
+
75
+ const toAccount = (row: Row): AuthAccount => ({
76
+ id: text(row, 'id'),
77
+ userId: text(row, 'user_id'),
78
+ provider: text(row, 'provider'),
79
+ providerAccountId: text(row, 'provider_account_id'),
80
+ accessToken: textOrNull(row, 'access_token'),
81
+ refreshToken: textOrNull(row, 'refresh_token'),
82
+ expiresAt: dateOrNull(row, 'expires_at'),
83
+ createdAt: date(row, 'created_at'),
84
+ });
85
+
86
+ const toVerification = (row: Row): AuthVerification => ({
87
+ id: text(row, 'id'),
88
+ purpose: text(row, 'purpose'),
89
+ identifier: text(row, 'identifier'),
90
+ tokenHash: text(row, 'token_hash'),
91
+ expiresAt: date(row, 'expires_at'),
92
+ consumedAt: dateOrNull(row, 'consumed_at'),
93
+ createdAt: date(row, 'created_at'),
94
+ });
95
+
96
+ const toApiKey = (row: Row): AuthApiKeyRecord => ({
97
+ id: text(row, 'id'),
98
+ prefix: text(row, 'prefix'),
99
+ keyHash: text(row, 'key_hash'),
100
+ userId: textOrNull(row, 'user_id'),
101
+ orgId: textOrNull(row, 'org_id'),
102
+ scopes: list(row, 'scopes'),
103
+ lastUsedAt: dateOrNull(row, 'last_used_at'),
104
+ expiresAt: dateOrNull(row, 'expires_at'),
105
+ revokedAt: dateOrNull(row, 'revoked_at'),
106
+ createdAt: date(row, 'created_at'),
107
+ });
108
+
109
+ /**
110
+ * A patch column is written as `case when <set> then <value> else <column> end` rather than
111
+ * assembled into dynamic SQL: `null` stays a meaningful value ("clear this field") and the
112
+ * statement text remains constant, so the query plan is cached and nothing is interpolated.
113
+ */
114
+ export class BuiltinAdapter implements AuthAdapter {
115
+ readonly name = 'builtin-postgres';
116
+ readonly #db: DbClient;
117
+
118
+ constructor(client: DbClient = db()) {
119
+ this.#db = client;
120
+ }
121
+
122
+ async findUserByEmail(email: string): Promise<AuthUser | null> {
123
+ const row = await this.#db.one<Row>(sql`select * from x_users where email = ${email}`);
124
+ return row === null ? null : toUser(row);
125
+ }
126
+
127
+ async findUserById(id: string): Promise<AuthUser | null> {
128
+ const row = await this.#db.one<Row>(sql`select * from x_users where id = ${id}`);
129
+ return row === null ? null : toUser(row);
130
+ }
131
+
132
+ async createUser(input: CreateUserInput): Promise<AuthUser> {
133
+ const row = await this.#db.one<Row>(sql`
134
+ insert into x_users (id, email, password_hash, org_id, roles, created_at)
135
+ values (${input.id}, ${input.email}, ${input.passwordHash}, ${input.orgId},
136
+ ${[...input.roles]}, ${input.createdAt})
137
+ returning *`);
138
+ // An empty `returning` means no row landed. A user fabricated from `{}` would travel back
139
+ // out of `register()` as a successful registration with no identity in it.
140
+ if (row === null) throw authWriteFailed('createUser', 'x_users');
141
+ return toUser(row);
142
+ }
143
+
144
+ async updateUser(id: string, patch: UserPatch): Promise<AuthUser | null> {
145
+ const row = await this.#db.one<Row>(sql`
146
+ update x_users set
147
+ password_hash = case when ${patch.passwordHash !== undefined}
148
+ then ${patch.passwordHash ?? null} else password_hash end,
149
+ email_verified_at = case when ${patch.emailVerifiedAt !== undefined}
150
+ then ${patch.emailVerifiedAt ?? null} else email_verified_at end,
151
+ mfa_secret = case when ${patch.mfaSecret !== undefined}
152
+ then ${patch.mfaSecret ?? null} else mfa_secret end,
153
+ recovery_code_hashes = case when ${patch.recoveryCodeHashes !== undefined}
154
+ then ${[...(patch.recoveryCodeHashes ?? [])]} else recovery_code_hashes end,
155
+ disabled_at = case when ${patch.disabledAt !== undefined}
156
+ then ${patch.disabledAt ?? null} else disabled_at end,
157
+ roles = case when ${patch.roles !== undefined}
158
+ then ${[...(patch.roles ?? [])]} else roles end
159
+ where id = ${id}
160
+ returning *`);
161
+ return row === null ? null : toUser(row);
162
+ }
163
+
164
+ async getSession(id: string): Promise<AuthSession | null> {
165
+ const row = await this.#db.one<Row>(sql`select * from x_sessions where id = ${id}`);
166
+ return row === null ? null : toSession(row);
167
+ }
168
+
169
+ async createSession(session: AuthSession): Promise<AuthSession> {
170
+ await this.#db.execute(sql`
171
+ insert into x_sessions (id, user_id, token_hash, created_at, absolute_expires_at,
172
+ last_seen_at, ip, user_agent, mfa_satisfied)
173
+ values (${session.id}, ${session.userId}, ${session.tokenHash}, ${session.createdAt},
174
+ ${session.absoluteExpiresAt}, ${session.lastSeenAt}, ${session.ip},
175
+ ${session.userAgent}, ${session.mfaSatisfied})`);
176
+ return session;
177
+ }
178
+
179
+ async updateSession(id: string, patch: SessionPatch): Promise<AuthSession | null> {
180
+ const row = await this.#db.one<Row>(sql`
181
+ update x_sessions set
182
+ last_seen_at = case when ${patch.lastSeenAt !== undefined}
183
+ then ${patch.lastSeenAt ?? null} else last_seen_at end,
184
+ ip = case when ${patch.ip !== undefined} then ${patch.ip ?? null} else ip end,
185
+ user_agent = case when ${patch.userAgent !== undefined}
186
+ then ${patch.userAgent ?? null} else user_agent end,
187
+ mfa_satisfied = case when ${patch.mfaSatisfied !== undefined}
188
+ then ${patch.mfaSatisfied ?? false} else mfa_satisfied end
189
+ where id = ${id}
190
+ returning *`);
191
+ return row === null ? null : toSession(row);
192
+ }
193
+
194
+ async deleteSession(id: string): Promise<boolean> {
195
+ return (await this.#db.execute(sql`delete from x_sessions where id = ${id}`)) > 0;
196
+ }
197
+
198
+ async deleteOtherSessions(userId: string, keepSessionId: string): Promise<number> {
199
+ return await this.#db.execute(
200
+ sql`delete from x_sessions where user_id = ${userId} and id <> ${keepSessionId}`,
201
+ );
202
+ }
203
+
204
+ async listSessions(userId: string): Promise<readonly AuthSession[]> {
205
+ const rows = await this.#db.query<Row>(
206
+ sql`select * from x_sessions where user_id = ${userId} order by last_seen_at desc`,
207
+ );
208
+ return rows.map(toSession);
209
+ }
210
+
211
+ async linkAccount(account: AuthAccount): Promise<AuthAccount> {
212
+ await this.#db.execute(sql`
213
+ insert into x_accounts (id, user_id, provider, provider_account_id, access_token,
214
+ refresh_token, expires_at, created_at)
215
+ values (${account.id}, ${account.userId}, ${account.provider}, ${account.providerAccountId},
216
+ ${account.accessToken}, ${account.refreshToken}, ${account.expiresAt},
217
+ ${account.createdAt})
218
+ on conflict (provider, provider_account_id) do update
219
+ set access_token = excluded.access_token, refresh_token = excluded.refresh_token,
220
+ expires_at = excluded.expires_at`);
221
+ return account;
222
+ }
223
+
224
+ async findAccount(provider: string, providerAccountId: string): Promise<AuthAccount | null> {
225
+ const row = await this.#db.one<Row>(sql`
226
+ select * from x_accounts
227
+ where provider = ${provider} and provider_account_id = ${providerAccountId}`);
228
+ return row === null ? null : toAccount(row);
229
+ }
230
+
231
+ async listAccounts(userId: string): Promise<readonly AuthAccount[]> {
232
+ const rows = await this.#db.query<Row>(sql`select * from x_accounts where user_id = ${userId}`);
233
+ return rows.map(toAccount);
234
+ }
235
+
236
+ async putVerification(record: AuthVerification): Promise<void> {
237
+ await this.#db.execute(sql`
238
+ insert into x_verifications (id, purpose, identifier, token_hash, expires_at, created_at)
239
+ values (${record.id}, ${record.purpose}, ${record.identifier}, ${record.tokenHash},
240
+ ${record.expiresAt}, ${record.createdAt})
241
+ on conflict (purpose, identifier) do update
242
+ set id = excluded.id, token_hash = excluded.token_hash,
243
+ expires_at = excluded.expires_at, created_at = excluded.created_at,
244
+ consumed_at = null`);
245
+ }
246
+
247
+ /** The `consumed_at is null` predicate is what makes redemption single-use under concurrency. */
248
+ async takeVerification(purpose: string, identifier: string): Promise<AuthVerification | null> {
249
+ const row = await this.#db.one<Row>(sql`
250
+ update x_verifications set consumed_at = now()
251
+ where purpose = ${purpose} and identifier = ${identifier} and consumed_at is null
252
+ returning *`);
253
+ return row === null ? null : toVerification(row);
254
+ }
255
+
256
+ async putApiKey(record: AuthApiKeyRecord): Promise<AuthApiKeyRecord> {
257
+ await this.#db.execute(sql`
258
+ insert into x_api_keys (id, prefix, key_hash, user_id, org_id, scopes, expires_at, created_at)
259
+ values (${record.id}, ${record.prefix}, ${record.keyHash}, ${record.userId},
260
+ ${record.orgId}, ${[...record.scopes]}, ${record.expiresAt}, ${record.createdAt})`);
261
+ return record;
262
+ }
263
+
264
+ async findApiKeyById(id: string): Promise<AuthApiKeyRecord | null> {
265
+ const row = await this.#db.one<Row>(sql`select * from x_api_keys where id = ${id}`);
266
+ return row === null ? null : toApiKey(row);
267
+ }
268
+
269
+ async listApiKeys(ownerId: string): Promise<readonly AuthApiKeyRecord[]> {
270
+ const rows = await this.#db.query<Row>(sql`
271
+ select * from x_api_keys where user_id = ${ownerId} or org_id = ${ownerId}
272
+ order by created_at desc`);
273
+ return rows.map(toApiKey);
274
+ }
275
+
276
+ async touchApiKey(id: string, at: Date): Promise<void> {
277
+ await this.#db.execute(sql`update x_api_keys set last_used_at = ${at} where id = ${id}`);
278
+ }
279
+
280
+ async revokeApiKey(id: string, at: Date): Promise<boolean> {
281
+ const changed = await this.#db.execute(
282
+ sql`update x_api_keys set revoked_at = ${at} where id = ${id} and revoked_at is null`,
283
+ );
284
+ return changed > 0;
285
+ }
286
+ }