@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # @ultimat3/auth 🔐
2
+
3
+ **The output of authentication is an `Actor` from `@ultimat3/core`.** Nothing downstream
4
+ authorizes on a session row, a user row or an api key — http, actions, jobs and MCP all read
5
+ `ctx.actor` and hand it to `@ultimat3/policy`. One authz system, never two.
6
+
7
+ ```ts
8
+ import { BuiltinAdapter, defineAuth, login } from '@ultimat3/auth';
9
+
10
+ export const auth = defineAuth({
11
+ adapter: new BuiltinAdapter(), // or MemoryAdapter, or your Better Auth binding
12
+ session: { absoluteTtlMs: 30 * 864e5, idleTtlMs: 7 * 864e5 },
13
+ password: { minLength: 12 },
14
+ mfa: { issuer: 'Acme' },
15
+ providers: ['github', 'google'],
16
+ });
17
+
18
+ const { actor, token, cookie } = await login(auth, { email, password, ip });
19
+ ```
20
+
21
+ ## Rules
22
+
23
+ - Every login failure throws `loginFailed()` from `rate-limit.ts`. Never a specific message.
24
+ - Session ids are opaque random tokens; only `sha256(secret)` reaches the database.
25
+ - Absolute and idle expiry are evaluated **independently**. Activity never moves the ceiling.
26
+ - PKCE is mandatory on every provider. A missing verifier fails the callback.
27
+ - Recovery codes, verification tokens and api keys are hashed at rest and single-use.
28
+ - Guards assert on the actor. They never evaluate a policy.
29
+
30
+ ## Adapter seam
31
+
32
+ `AuthAdapter` (`adapter.ts`) is the only persistence interface. Better Auth binds here — it is
33
+ an adapter implementation, not a dependency of this package.
34
+
35
+ | Driver | Use |
36
+ |---|---|
37
+ | `BuiltinAdapter` | Postgres via `@ultimat3/db`; takes an injected `DbClient` |
38
+ | `MemoryAdapter` | `x new` before a database exists, and every test in this package |
39
+ | your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
40
+
41
+ ```bash
42
+ x db gen "auth tables" # emits AUTH_TABLES into a migration
43
+ ```
44
+
45
+ ## Cookie
46
+
47
+ `__Host-x_session`, set by `sessionCookie(token, policy)`.
48
+
49
+ | Attribute | Attack it closes |
50
+ |---|---|
51
+ | `HttpOnly` | XSS reading `document.cookie` and exfiltrating the session |
52
+ | `Secure` | a network attacker lifting it off a plaintext request |
53
+ | `SameSite=Lax` | CSRF — the cookie is not attached to cross-site POSTs |
54
+ | `__Host-` + `Path=/` + no `Domain` | a sibling subdomain overwriting it (session fixation) |
55
+ | `Max-Age` | a client keeping it past the server's absolute ceiling |
56
+
57
+ ## OAuth
58
+
59
+ Two calls: one to leave, one to come back. Provider configs are pure data — importing
60
+ `oauth.ts` performs no network I/O and reads no env.
61
+
62
+ ```ts
63
+ // GET /auth/oauth/:provider — redirect, keeping nothing on the server
64
+ export async function GET(request: Request): Promise<Response> {
65
+ const handshake = beginOAuth({ provider: 'github', clientId, redirectUri });
66
+ return new Response(null, {
67
+ status: 302,
68
+ headers: { location: handshake.authorizeUrl, 'set-cookie': handshakeCookie(handshake) },
69
+ });
70
+ }
71
+ ```
72
+
73
+ ```ts
74
+ // GET /auth/oauth/:provider/callback — a separate request; the cookie is all that crossed
75
+ export async function GET(request: Request): Promise<Response> {
76
+ const url = new URL(request.url);
77
+ const { cookie } = await completeOAuthLogin(auth, {
78
+ handshake: readHandshakeCookie(request, 'github'),
79
+ callback: { state: url.searchParams.get('state') ?? '', code: url.searchParams.get('code') ?? '' },
80
+ });
81
+ const headers = new Headers({ location: '/' });
82
+ // Both, always: a code is single-use, so the handshake that authorised it must not outlive it.
83
+ headers.append('set-cookie', cookie);
84
+ headers.append('set-cookie', clearHandshakeCookie('github'));
85
+ return new Response(null, { status: 302, headers });
86
+ }
87
+ ```
88
+
89
+ The handshake carries `state`, `nonce` and the PKCE verifier across two requests, so it needs a
90
+ home. `handshakeCookie` is that home — sealed with `SESSION_SECRET`, `HttpOnly; Secure;
91
+ SameSite=Lax` under a `__Host-` name, and expired against the server's clock rather than the
92
+ client's copy of `Max-Age`. `sealHandshake` / `openHandshake` are the same codec without the
93
+ cookie, for an app that would rather keep it server-side.
94
+
95
+ **One cookie per provider:** `handshakeCookieName(provider)` → `__Host-x_oauth_github`. A browser
96
+ is one cookie jar and a user is allowed two tabs, so a single shared name means the `google`
97
+ redirect overwrites a `github` handshake still in flight — and the github callback then opens
98
+ google's and fails `X_OAUTH_STATE_INVALID` for a reason no restart clears. `handshakeCookie` takes
99
+ the name off `handshake.provider`, `clearHandshakeCookie(provider)` clears only that provider's,
100
+ and `readHandshakeCookie(request, provider)` reads only that provider's. Pass `{ name }` to
101
+ override all three at once.
102
+
103
+ | Refused | Because |
104
+ |---|---|
105
+ | a handshake with no signature, or one signed with another secret | a browser that can mint a handshake can pair its own code with someone else's session |
106
+ | a `github` handshake opened on the `google` callback | `openHandshake(sealed, provider)` requires the provider, so it cannot be forgotten |
107
+ | a handshake older than `DEFAULT_HANDSHAKE_TTL_MS` (10 min) | a client may ignore `Max-Age`; the server's clock decides |
108
+ | a callback with no handshake cookie | there is nothing to check `state` against |
109
+ | a cookie value that is not valid percent-encoding | the header is the client's; the raw value reaches the signature check and fails it, never a bare `URIError` |
110
+
111
+ | Provider | PKCE | id token | Env |
112
+ |---|---|---|---|
113
+ | `github` | S256 | — profile + verified-emails call | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` |
114
+ | `google` | S256 | required, nonce-bound | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` |
115
+ | `apple` | S256 | required, nonce-bound | `APPLE_CLIENT_ID` / `APPLE_CLIENT_SECRET` |
116
+
117
+ Apple alone rejects a static secret: `APPLE_CLIENT_SECRET` must hold the ES256 client-secret
118
+ JWT signed with the `.p8` key, which Apple expires every six months.
119
+
120
+ | Step | Does | Fails with |
121
+ |---|---|---|
122
+ | `handshakeCookie` / `readHandshakeCookie` | seals the handshake onto the redirect, opens it on the callback | `X_OAUTH_STATE_INVALID`, `X_ENV_MISSING` |
123
+ | `exchangeOAuthCode` | POSTs the code + PKCE verifier, verifies the id token | `X_OAUTH_EXCHANGE_FAILED`, `X_OAUTH_TOKEN_INVALID` |
124
+ | `oauthProfile` | id-token claims, else userinfo → one normalised identity | `X_OAUTH_EXCHANGE_FAILED` |
125
+ | `signInWithOAuth` | links the account, applies MFA, mints the session | `X_UNAUTHENTICATED`, `X_MFA_REQUIRED` |
126
+
127
+ - PKCE's verifier travels only in the exchange — it proves the code belongs to the browser
128
+ that started the flow.
129
+ - `state` is checked before anything reaches the network; `nonce` is checked inside the id
130
+ token, because that is where the code flow actually carries it.
131
+ - GitHub reports a bad, reused or expired code as **HTTP 200 with an `error` field**. Trusting
132
+ the status alone there mints a session from a failed exchange.
133
+ - An address is only linked to an existing account when **both** sides verified it. Otherwise
134
+ whoever registered the address first inherits the login.
135
+
136
+ ## API keys — how an agent authenticates
137
+
138
+ `ult_<env>_<id>_<secret>`. The plaintext is shown once; the row holds `sha256(secret)` and is
139
+ looked up by the non-secret id.
140
+
141
+ ```ts
142
+ const { plaintext, record } = issueApiKey({ env: 'prod', scopes: ['post:publish'], orgId });
143
+ await auth.adapter.putApiKey(record);
144
+ const actor = apiKeyActor(await verifyApiKey(auth.adapter, plaintext)); // kind: 'agent'
145
+ ```
146
+
147
+ An api key's scopes become **exactly** the agent actor's scopes — never the owning user's roles.
148
+
149
+ ## Errors
150
+
151
+ | Code | When |
152
+ |---|---|
153
+ | `X_UNAUTHENTICATED` | no actor, unknown session, or any failed credential path |
154
+ | `X_SESSION_EXPIRED` | idle or absolute expiry, named in `cause` |
155
+ | `X_MFA_REQUIRED` | password proven, second factor outstanding |
156
+ | `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
157
+ | `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
158
+ | `X_OAUTH_TOKEN_INVALID` | the id token failed its issuer, audience or expiry check |
159
+ | `X_PASSWORD_WEAK` | strength check rejected the password |
160
+ | `X_ACCOUNT_LOCKED` | per-ip or per-account bucket is inside its lockout |
161
+ | `X_API_KEY_INVALID` | key unknown, revoked, expired or wrong |
162
+ | `X_ENV_MISSING` | `oauthCredentials()` found no client id or secret for an enabled provider |
163
+ | `X_NOT_IMPLEMENTED` | an `AuthAdapter` refused a method (`authNotImplemented(feature, fix)`), or lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |
164
+
165
+ ```bash
166
+ bun test packages/auth
167
+ bun run --filter @ultimat3/auth typecheck
168
+ ```
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@ultimat3/auth",
3
+ "version": "1.0.0",
4
+ "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/auth"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.3.0"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
30
+ "test": "bun test"
31
+ },
32
+ "dependencies": {
33
+ "@ultimat3/core": "1.0.0",
34
+ "@ultimat3/db": "1.0.0",
35
+ "@ultimat3/schema": "1.0.0"
36
+ }
37
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,157 @@
1
+ // Single responsibility: the persistence seam. `AuthAdapter` is the one interface auth talks
2
+ // to, split into per-concern stores so a test (or a caller) can satisfy just the slice it uses.
3
+ // Better Auth binds here — it is an adapter implementation, not a dependency. The blessed
4
+ // default is `BuiltinAdapter` in `builtin-adapter.ts`; the DDL it expects is in `tables.ts`.
5
+
6
+ export interface AuthUser {
7
+ readonly id: string;
8
+ readonly email: string;
9
+ readonly emailVerifiedAt: Date | null;
10
+ /** `null` for an OAuth-only account. Never a plaintext password. */
11
+ readonly passwordHash: string | null;
12
+ readonly orgId: string | null;
13
+ /** Authz roles (`editor`), expanded to permissions by `@ultimat3/policy`. */
14
+ readonly roles: readonly string[];
15
+ /** Direct grants that bypass roles. Rare; used by break-glass accounts. */
16
+ readonly permissions: readonly string[];
17
+ /** Base32 TOTP secret, or `null` when MFA is not enrolled. */
18
+ readonly mfaSecret: string | null;
19
+ readonly recoveryCodeHashes: readonly string[];
20
+ readonly disabledAt: Date | null;
21
+ readonly createdAt: Date;
22
+ }
23
+
24
+ export interface CreateUserInput {
25
+ readonly id: string;
26
+ readonly email: string;
27
+ readonly passwordHash: string | null;
28
+ readonly orgId: string | null;
29
+ readonly roles: readonly string[];
30
+ readonly createdAt: Date;
31
+ }
32
+
33
+ export interface UserPatch {
34
+ readonly passwordHash?: string | null | undefined;
35
+ readonly emailVerifiedAt?: Date | null | undefined;
36
+ readonly mfaSecret?: string | null | undefined;
37
+ readonly recoveryCodeHashes?: readonly string[] | undefined;
38
+ readonly disabledAt?: Date | null | undefined;
39
+ readonly roles?: readonly string[] | undefined;
40
+ }
41
+
42
+ export interface UserStore {
43
+ findUserByEmail(email: string): Promise<AuthUser | null>;
44
+ findUserById(id: string): Promise<AuthUser | null>;
45
+ createUser(input: CreateUserInput): Promise<AuthUser>;
46
+ updateUser(id: string, patch: UserPatch): Promise<AuthUser | null>;
47
+ }
48
+
49
+ export interface AuthSession {
50
+ /** Public, non-secret lookup key. The secret half of the cookie never reaches the row. */
51
+ readonly id: string;
52
+ readonly userId: string;
53
+ /** SHA-256 of the token secret. A DB dump is not a session-hijack kit. */
54
+ readonly tokenHash: string;
55
+ readonly createdAt: Date;
56
+ /** Hard ceiling, never extended by activity. */
57
+ readonly absoluteExpiresAt: Date;
58
+ /** Moves on every request; `idleTtlMs` is measured from here. */
59
+ readonly lastSeenAt: Date;
60
+ readonly ip: string | null;
61
+ readonly userAgent: string | null;
62
+ readonly mfaSatisfied: boolean;
63
+ }
64
+
65
+ export interface SessionPatch {
66
+ readonly lastSeenAt?: Date | undefined;
67
+ readonly ip?: string | null | undefined;
68
+ readonly userAgent?: string | null | undefined;
69
+ readonly mfaSatisfied?: boolean | undefined;
70
+ }
71
+
72
+ export interface SessionStore {
73
+ getSession(id: string): Promise<AuthSession | null>;
74
+ createSession(session: AuthSession): Promise<AuthSession>;
75
+ updateSession(id: string, patch: SessionPatch): Promise<AuthSession | null>;
76
+ deleteSession(id: string): Promise<boolean>;
77
+ /** Returns how many were killed — the "sign out everywhere else" number shown to the user. */
78
+ deleteOtherSessions(userId: string, keepSessionId: string): Promise<number>;
79
+ listSessions(userId: string): Promise<readonly AuthSession[]>;
80
+ }
81
+
82
+ export interface AuthAccount {
83
+ readonly id: string;
84
+ readonly userId: string;
85
+ readonly provider: string;
86
+ readonly providerAccountId: string;
87
+ readonly accessToken: string | null;
88
+ readonly refreshToken: string | null;
89
+ readonly expiresAt: Date | null;
90
+ readonly createdAt: Date;
91
+ }
92
+
93
+ export interface AccountStore {
94
+ linkAccount(account: AuthAccount): Promise<AuthAccount>;
95
+ findAccount(provider: string, providerAccountId: string): Promise<AuthAccount | null>;
96
+ listAccounts(userId: string): Promise<readonly AuthAccount[]>;
97
+ }
98
+
99
+ export interface AuthVerification {
100
+ readonly id: string;
101
+ /** `email-verify` | `password-reset` — see `verify.ts`. */
102
+ readonly purpose: string;
103
+ /** The email address the token was issued for. */
104
+ readonly identifier: string;
105
+ readonly tokenHash: string;
106
+ readonly expiresAt: Date;
107
+ readonly consumedAt: Date | null;
108
+ readonly createdAt: Date;
109
+ }
110
+
111
+ export interface VerificationStore {
112
+ /** Upsert on `(purpose, identifier)` — issuing a new token invalidates the previous one. */
113
+ putVerification(record: AuthVerification): Promise<void>;
114
+ /**
115
+ * Read **and consume** in one atomic step. Single-use is a storage guarantee, not a
116
+ * caller convention: two concurrent redemptions must not both see an unconsumed row.
117
+ */
118
+ takeVerification(purpose: string, identifier: string): Promise<AuthVerification | null>;
119
+ }
120
+
121
+ export interface AuthApiKeyRecord {
122
+ /** The non-secret half of the token; the lookup key. */
123
+ readonly id: string;
124
+ /** `ult_<env>_<id>` — safe to display, safe to log. */
125
+ readonly prefix: string;
126
+ readonly keyHash: string;
127
+ readonly userId: string | null;
128
+ readonly orgId: string | null;
129
+ /** Exactly the scopes the agent actor gets. Never widened at resolve time. */
130
+ readonly scopes: readonly string[];
131
+ readonly lastUsedAt: Date | null;
132
+ readonly expiresAt: Date | null;
133
+ readonly revokedAt: Date | null;
134
+ readonly createdAt: Date;
135
+ }
136
+
137
+ export interface ApiKeyStore {
138
+ putApiKey(record: AuthApiKeyRecord): Promise<AuthApiKeyRecord>;
139
+ findApiKeyById(id: string): Promise<AuthApiKeyRecord | null>;
140
+ listApiKeys(ownerId: string): Promise<readonly AuthApiKeyRecord[]>;
141
+ touchApiKey(id: string, at: Date): Promise<void>;
142
+ revokeApiKey(id: string, at: Date): Promise<boolean>;
143
+ }
144
+
145
+ /**
146
+ * The full seam. One blessed implementation ships (`BuiltinAdapter`); Better Auth, or any
147
+ * other identity backend, binds by implementing this and nothing else changes upstream.
148
+ */
149
+ export interface AuthAdapter
150
+ extends UserStore,
151
+ SessionStore,
152
+ AccountStore,
153
+ VerificationStore,
154
+ ApiKeyStore {
155
+ /** Shown by `x auth doctor --json` so the driver in use is never a guess. */
156
+ readonly name: string;
157
+ }
@@ -0,0 +1,141 @@
1
+ // Single responsibility: machine credentials. This is how an agent — an MCP client driving a
2
+ // generated Ultimate app — authenticates: it presents a key, the key resolves to an
3
+ // `agentActor` carrying exactly the key's scopes, and it goes through the same policy
4
+ // evaluation a human does. The plaintext is shown once; only its SHA-256 is ever stored, and
5
+ // lookup happens by the non-secret id so the secret never appears in a query, an index or a log.
6
+
7
+ import { type Clock, randomHex, systemClock } from '@ultimat3/core';
8
+ import type { ApiKeyStore, AuthApiKeyRecord } from './adapter';
9
+ import { apiKeyInvalid } from './errors';
10
+ import type { PolicyActor } from './policy-bridge';
11
+ import { actorFromApiKey } from './policy-bridge';
12
+ import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
13
+
14
+ export const API_KEY_NAMESPACE = 'ult';
15
+
16
+ /** `ult_<env>_<id>_<secret>` — the first three segments are the displayable prefix. */
17
+ export const API_KEY_PREFIX_SEGMENTS = 3;
18
+
19
+ export interface ParsedApiKey {
20
+ readonly env: string;
21
+ readonly id: string;
22
+ readonly prefix: string;
23
+ readonly secret: string;
24
+ }
25
+
26
+ export function apiKeyPrefix(env: string, id: string): string {
27
+ return `${API_KEY_NAMESPACE}_${env}_${id}`;
28
+ }
29
+
30
+ /**
31
+ * Split on `_` with a limit: the secret is base64url and may itself contain `_`, so the tail
32
+ * is rejoined rather than assumed to be a single segment.
33
+ */
34
+ export function parseApiKey(plaintext: string): ParsedApiKey | null {
35
+ const parts = plaintext.split('_');
36
+ if (parts.length <= API_KEY_PREFIX_SEGMENTS) return null;
37
+ const [namespace, env, id] = parts;
38
+ if (namespace !== API_KEY_NAMESPACE || env === undefined || id === undefined) return null;
39
+ if (env.length === 0 || id.length === 0) return null;
40
+ const secret = parts.slice(API_KEY_PREFIX_SEGMENTS).join('_');
41
+ if (secret.length === 0) return null;
42
+ return { env, id, prefix: apiKeyPrefix(env, id), secret };
43
+ }
44
+
45
+ export interface IssueApiKeyInput {
46
+ /** `dev` | `stage` | `prod` — visible in the token so a leaked key is triageable at a glance. */
47
+ readonly env: string;
48
+ readonly scopes: readonly string[];
49
+ readonly userId?: string | null | undefined;
50
+ readonly orgId?: string | null | undefined;
51
+ readonly expiresAt?: Date | null | undefined;
52
+ readonly clock?: Clock | undefined;
53
+ }
54
+
55
+ export interface IssuedApiKey {
56
+ /** Shown once. Nothing in `record` can reproduce it. */
57
+ readonly plaintext: string;
58
+ readonly record: AuthApiKeyRecord;
59
+ }
60
+
61
+ export function issueApiKey(input: IssueApiKeyInput): IssuedApiKey {
62
+ const clock = input.clock ?? systemClock;
63
+ // Hex, not base64url: the id sits between two `_` delimiters and must not contain one.
64
+ const id = randomHex(8);
65
+ const secret = randomToken(32);
66
+ const plaintext = `${apiKeyPrefix(input.env, id)}_${secret}`;
67
+ return {
68
+ plaintext,
69
+ record: {
70
+ id,
71
+ prefix: apiKeyPrefix(input.env, id),
72
+ keyHash: sha256Hex(secret),
73
+ userId: input.userId ?? null,
74
+ orgId: input.orgId ?? null,
75
+ scopes: [...input.scopes],
76
+ lastUsedAt: null,
77
+ expiresAt: input.expiresAt ?? null,
78
+ revokedAt: null,
79
+ createdAt: clock.now(),
80
+ },
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Every rejection — malformed, unknown, revoked, expired, wrong secret — throws the same
86
+ * `X_API_KEY_INVALID`. A caller that can tell "revoked" from "unknown" can enumerate ids.
87
+ */
88
+ export async function verifyApiKey(
89
+ store: ApiKeyStore,
90
+ plaintext: string,
91
+ clock: Clock = systemClock,
92
+ ): Promise<AuthApiKeyRecord> {
93
+ const parsed = parseApiKey(plaintext);
94
+ if (parsed === null) throw apiKeyInvalid();
95
+ const record = await store.findApiKeyById(parsed.id);
96
+ if (record === null) throw apiKeyInvalid();
97
+ if (record.revokedAt !== null) throw apiKeyInvalid();
98
+ const now = clock.now();
99
+ if (record.expiresAt !== null && now.getTime() >= record.expiresAt.getTime()) {
100
+ throw apiKeyInvalid();
101
+ }
102
+ if (!timingSafeEqual(sha256Hex(parsed.secret), record.keyHash)) throw apiKeyInvalid();
103
+ await store.touchApiKey(record.id, now);
104
+ return record;
105
+ }
106
+
107
+ export async function revokeApiKey(
108
+ store: ApiKeyStore,
109
+ id: string,
110
+ clock: Clock = systemClock,
111
+ ): Promise<boolean> {
112
+ return await store.revokeApiKey(id, clock.now());
113
+ }
114
+
115
+ /** The agent actor for a verified key. Scopes in, scopes out — nothing is added. */
116
+ export function apiKeyActor(record: AuthApiKeyRecord): PolicyActor {
117
+ return actorFromApiKey(record);
118
+ }
119
+
120
+ /** Safe to render in a dashboard or return from an MCP tool: no hash, no secret. */
121
+ export interface ApiKeySummary {
122
+ readonly id: string;
123
+ readonly prefix: string;
124
+ readonly scopes: readonly string[];
125
+ readonly lastUsedAt: Date | null;
126
+ readonly expiresAt: Date | null;
127
+ readonly revokedAt: Date | null;
128
+ readonly createdAt: Date;
129
+ }
130
+
131
+ export function describeApiKey(record: AuthApiKeyRecord): ApiKeySummary {
132
+ return {
133
+ id: record.id,
134
+ prefix: record.prefix,
135
+ scopes: record.scopes,
136
+ lastUsedAt: record.lastUsedAt,
137
+ expiresAt: record.expiresAt,
138
+ revokedAt: record.revokedAt,
139
+ createdAt: record.createdAt,
140
+ };
141
+ }