@ultimat3/auth 22.14.0 → 23.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 +5 -0
- package/README.md +29 -0
- package/package.json +5 -4
- package/src/api-key-resolver.ts +50 -0
- package/src/index.ts +2 -0
- package/src/auth-fixture.ts +0 -26
- package/src/id-token-fixture.ts +0 -20
- package/src/oauth-login-fixture.ts +0 -53
package/CLAUDE.md
CHANGED
|
@@ -135,6 +135,11 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
135
135
|
`deleteSessionsForUser`, `deleteSessionsForOrg`, `deleteSessionsCreatedBefore`); callers throw
|
|
136
136
|
`X_NOT_IMPLEMENTED` naming the method.
|
|
137
137
|
- An api key's scopes are the agent actor's scopes. Never union them with the owner's roles.
|
|
138
|
+
- **`apiKeyResolver(() => store)` is the ONE token → caller mapping** (`api-key-resolver.ts`):
|
|
139
|
+
`verifyApiKey` → `apiKeyActor`, `X_API_KEY_INVALID` → `null`, everything else rethrown. Its
|
|
140
|
+
`ApiKeyCaller` is structurally http's `BearerCaller` and mcp's `ResolvedToken` — declared here
|
|
141
|
+
because tier 2 imports neither. The store is a thunk (a mount is declared before boot installs
|
|
142
|
+
the database client). No second in-memory key store: `MemoryAdapter` IS an `ApiKeyStore`.
|
|
138
143
|
- Rotate the session id on any privilege change (`rotateSession`, called by `updatePrivileges` in
|
|
139
144
|
`privileges.ts`), never patch the row.
|
|
140
145
|
- **Every argon2 call goes through `kdfGate()`** — width 8, queue 64, `X_OVERLOADED` past it
|
package/README.md
CHANGED
|
@@ -663,6 +663,35 @@ const actor = apiKeyActor(await verifyApiKey(auth.adapter, plaintext)); // kind
|
|
|
663
663
|
|
|
664
664
|
An api key's scopes become **exactly** the agent actor's scopes — never the owning user's roles.
|
|
665
665
|
|
|
666
|
+
### The resolver a mount takes
|
|
667
|
+
|
|
668
|
+
`apiKeyResolver(() => store)` is that last line as the `resolveToken` a bearer mount
|
|
669
|
+
(`defineApi({ http: { mounts } })`) and an MCP endpoint (`defineAppMcp`) both take — one resolver
|
|
670
|
+
for both doors.
|
|
671
|
+
|
|
672
|
+
```ts
|
|
673
|
+
import type { ApiKeyStore } from '@ultimat3/auth';
|
|
674
|
+
import { apiKeyResolver, BuiltinAdapter } from '@ultimat3/auth';
|
|
675
|
+
|
|
676
|
+
let keys: ApiKeyStore | undefined;
|
|
677
|
+
|
|
678
|
+
/** `resolveToken: resolveKey` — on the mount, and on `defineAppMcp()`. */
|
|
679
|
+
export const resolveKey = apiKeyResolver(() => {
|
|
680
|
+
keys ??= new BuiltinAdapter();
|
|
681
|
+
return keys;
|
|
682
|
+
});
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
| Presented | Answer |
|
|
686
|
+
|---|---|
|
|
687
|
+
| a live key | `{ actor, scopes }` — the agent actor for the key's org, and its scopes as a `Set` |
|
|
688
|
+
| malformed, unknown, wrong secret, revoked, expired | `null`, all five alike: the mount answers one indistinguishable 401 |
|
|
689
|
+
| a store that FAILS | the throw, untouched — a database that is down is not a wrong key |
|
|
690
|
+
|
|
691
|
+
The store is a THUNK, read when a token is presented: a mount is declared when its module is
|
|
692
|
+
evaluated, and `new BuiltinAdapter()` takes the process's database client, which boot installs
|
|
693
|
+
later. In a test the store is `new MemoryAdapter()` — every `AuthAdapter` is an `ApiKeyStore`.
|
|
694
|
+
|
|
666
695
|
## Errors
|
|
667
696
|
|
|
668
697
|
| Code | When |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "23.0.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*-fixture.ts",
|
|
22
23
|
"CLAUDE.md",
|
|
23
24
|
"README.md",
|
|
24
25
|
"LICENSE"
|
|
@@ -31,8 +32,8 @@
|
|
|
31
32
|
"test": "bun test"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
35
|
+
"@ultimat3/core": "23.0.0",
|
|
36
|
+
"@ultimat3/db": "23.0.0",
|
|
37
|
+
"@ultimat3/schema": "23.0.0"
|
|
37
38
|
}
|
|
38
39
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Single responsibility: a presented API key → who is calling, in the shape a bearer mount and an
|
|
2
|
+
// MCP endpoint both take as `resolveToken`. `verifyApiKey` answers a record or throws; this is the
|
|
3
|
+
// one place that turns its ONE rejection into the `null` those surfaces answer 401 for — and
|
|
4
|
+
// leaves everything else a throw, because "the database is down" is not "your key is wrong".
|
|
5
|
+
|
|
6
|
+
import { type Clock, isUltimateError, systemClock } from '@ultimat3/core';
|
|
7
|
+
import type { ApiKeyStore } from './adapter';
|
|
8
|
+
import { apiKeyActor, verifyApiKey } from './api-keys';
|
|
9
|
+
import type { PolicyActor } from './policy-bridge';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a verified key resolves to. Structurally `@ultimat3/http`'s `BearerCaller` and
|
|
13
|
+
* `@ultimat3/mcp`'s `ResolvedToken` — declared here because this tier can import neither.
|
|
14
|
+
*/
|
|
15
|
+
export interface ApiKeyCaller {
|
|
16
|
+
/** The agent actor: the key's org, and its scopes as the permissions a policy reads. */
|
|
17
|
+
readonly actor: PolicyActor;
|
|
18
|
+
/** The key's scopes — what the mount's scope map is cut by. */
|
|
19
|
+
readonly scopes: ReadonlySet<string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ApiKeyResolverOptions {
|
|
23
|
+
readonly clock?: Clock | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `resolveToken: apiKeyResolver(() => keys)` on `defineApi({ http: { mounts } })` and on
|
|
28
|
+
* `defineAppMcp()` — one resolver for both.
|
|
29
|
+
*
|
|
30
|
+
* `store` is a THUNK, read when a token is presented and never captured: a mount is declared when
|
|
31
|
+
* its module is evaluated, and `new BuiltinAdapter()` takes the process's database client, which
|
|
32
|
+
* boot installs later. `() => new MemoryAdapter()` would be a new, empty store per request — hold
|
|
33
|
+
* the instance and return it.
|
|
34
|
+
*/
|
|
35
|
+
export function apiKeyResolver(
|
|
36
|
+
store: () => ApiKeyStore,
|
|
37
|
+
options: ApiKeyResolverOptions = {},
|
|
38
|
+
): (token: string) => Promise<ApiKeyCaller | null> {
|
|
39
|
+
return async (token) => {
|
|
40
|
+
try {
|
|
41
|
+
const record = await verifyApiKey(store(), token, options.clock ?? systemClock);
|
|
42
|
+
return { actor: apiKeyActor(record), scopes: new Set(record.scopes) };
|
|
43
|
+
} catch (thrown) {
|
|
44
|
+
// Every way a key can be wrong is this one code, by `verifyApiKey`'s own design: a caller
|
|
45
|
+
// that could tell "revoked" from "unknown" could enumerate ids.
|
|
46
|
+
if (isUltimateError(thrown) && thrown.code === 'X_API_KEY_INVALID') return null;
|
|
47
|
+
throw thrown;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type {
|
|
|
17
17
|
UserStore,
|
|
18
18
|
VerificationStore,
|
|
19
19
|
} from './adapter';
|
|
20
|
+
export type { ApiKeyCaller, ApiKeyResolverOptions } from './api-key-resolver';
|
|
21
|
+
export { apiKeyResolver } from './api-key-resolver';
|
|
20
22
|
export type { ApiKeySummary, IssueApiKeyInput, IssuedApiKey, ParsedApiKey } from './api-keys';
|
|
21
23
|
export {
|
|
22
24
|
API_KEY_NAMESPACE,
|
package/src/auth-fixture.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
// Single responsibility: the three values every credential-flow suite needs — cheap KDF
|
|
2
|
-
// parameters, the password they all sign in with, and the `AuthError` catcher. Three test files
|
|
3
|
-
// share them, and three private copies is three chances for one to drift from what the flow
|
|
4
|
-
// actually enforces. Not part of the public API — `index.ts` deliberately does not re-export it.
|
|
5
|
-
|
|
6
|
-
import { AuthError } from './errors';
|
|
7
|
-
import type { PasswordParams } from './password';
|
|
8
|
-
|
|
9
|
-
// Fast KDF parameters: these tests are about the credential flow, not argon2's cost.
|
|
10
|
-
export const FAST_PARAMS: PasswordParams = { algorithm: 'argon2id', memoryCost: 8192, timeCost: 1 };
|
|
11
|
-
|
|
12
|
-
export const PASSWORD = 'correct-horse-battery-staple-42';
|
|
13
|
-
|
|
14
|
-
/** Captures the thrown `AuthError`, or `undefined` when the call unexpectedly resolved. The
|
|
15
|
-
* caller's `expect(error?.code).toBe(...)` is then the assertion that fails, naming the code it
|
|
16
|
-
* wanted — a sentinel thrown from in here would carry no code and no fix. Anything that is not
|
|
17
|
-
* an `AuthError` is rethrown untouched: this helper never swallows an unexpected failure. */
|
|
18
|
-
export const caught = async (fn: () => Promise<unknown>): Promise<AuthError | undefined> => {
|
|
19
|
-
try {
|
|
20
|
-
await fn();
|
|
21
|
-
return undefined;
|
|
22
|
-
} catch (error) {
|
|
23
|
-
if (error instanceof AuthError) return error;
|
|
24
|
-
throw error;
|
|
25
|
-
}
|
|
26
|
-
};
|
package/src/id-token-fixture.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
// Single responsibility: the one way this package's tests mint an id token. Three OAuth test
|
|
2
|
-
// files each needs a base64url-encoded JWT, and three private copies of the encoder is three
|
|
3
|
-
// chances for one to drift from what `decodeIdToken` actually parses. Not part of the public
|
|
4
|
-
// API — `index.ts` deliberately does not re-export it.
|
|
5
|
-
|
|
6
|
-
import type { IdTokenClaims } from './id-token';
|
|
7
|
-
import { base64Url } from './tokens';
|
|
8
|
-
|
|
9
|
-
/** `base64Url` takes bytes because every real secret is bytes; a JWT segment is text. */
|
|
10
|
-
export const base64UrlText = (value: string): string => base64Url(new TextEncoder().encode(value));
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Header, payload, and a signature that is not one. Signatures are never checked here — the
|
|
14
|
-
* token is only ever read straight off the token endpoint — so a fixture needs no signer.
|
|
15
|
-
*/
|
|
16
|
-
// The union, and not `Record<string, unknown>` alone: `IdTokenClaims` is an `interface`, so it has
|
|
17
|
-
// no implicit index signature and the one shape this fixture exists to serialise was the one shape
|
|
18
|
-
// it refused. The record arm stays for the malformed payloads `id-token.test.ts` builds by hand.
|
|
19
|
-
export const unsignedJwt = (claims: IdTokenClaims | Readonly<Record<string, unknown>>): string =>
|
|
20
|
-
`${base64UrlText('{"alg":"RS256"}')}.${base64UrlText(JSON.stringify(claims))}.signature`;
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
// The fixtures the three `oauth-login` suites share: the frozen instant, a fresh
|
|
2
|
-
// `MemoryAdapter`-backed `Auth`, the two request bodies, the code of a rejection and a JSON
|
|
3
|
-
// `Response`. Shared rather than copied — three suites building their own `Auth` would be three
|
|
4
|
-
// flows that agree only by construction, the same reason `backfill-pass-fixture.ts` exists.
|
|
5
|
-
|
|
6
|
-
import { frozenClock, isUltimateError } from '@ultimat3/core';
|
|
7
|
-
import { type Auth, defineAuth } from './auth';
|
|
8
|
-
import { MemoryAdapter } from './memory-adapter';
|
|
9
|
-
import type { OAuthTokens } from './oauth-exchange';
|
|
10
|
-
import type { OAuthProfile } from './oauth-profile';
|
|
11
|
-
|
|
12
|
-
export const NOW = new Date('2026-08-09T12:00:00.000Z');
|
|
13
|
-
|
|
14
|
-
export const credentials = { clientId: 'client-id', clientSecret: 'client-secret' };
|
|
15
|
-
|
|
16
|
-
/** One adapter and the `Auth` over it, minted per `beforeEach` — never shared between tests. */
|
|
17
|
-
export const freshAuth = (): { adapter: MemoryAdapter; auth: Auth } => {
|
|
18
|
-
const adapter = new MemoryAdapter();
|
|
19
|
-
return {
|
|
20
|
-
adapter,
|
|
21
|
-
auth: defineAuth({ adapter, clock: frozenClock(NOW), providers: ['github', 'google'] }),
|
|
22
|
-
};
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export const profile = (overrides: Partial<OAuthProfile> = {}): OAuthProfile => ({
|
|
26
|
-
provider: 'github',
|
|
27
|
-
providerAccountId: '583231',
|
|
28
|
-
email: 'ada@example.com',
|
|
29
|
-
emailVerified: true,
|
|
30
|
-
name: 'Ada Lovelace',
|
|
31
|
-
...overrides,
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
export const tokens = (overrides: Partial<OAuthTokens> = {}): OAuthTokens => ({
|
|
35
|
-
accessToken: 'gho_first',
|
|
36
|
-
refreshToken: null,
|
|
37
|
-
expiresAt: null,
|
|
38
|
-
idToken: null,
|
|
39
|
-
claims: null,
|
|
40
|
-
...overrides,
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
export const codeOf = async (call: Promise<unknown>): Promise<string> => {
|
|
44
|
-
try {
|
|
45
|
-
await call;
|
|
46
|
-
} catch (error) {
|
|
47
|
-
return isUltimateError(error) ? error.code : `not-an-UltimateError: ${String(error)}`;
|
|
48
|
-
}
|
|
49
|
-
return 'did-not-throw';
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export const json = (body: unknown): Response =>
|
|
53
|
-
new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } });
|