@aooth/auth 0.1.7 → 0.1.8

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.
@@ -0,0 +1,252 @@
1
+ import { t as Clock } from "./clock-BjXa0LXb.cjs";
2
+
3
+ //#region src/authz/token-policy.d.ts
4
+ /**
5
+ * What the authorization server mints for a completed grant — forwarded verbatim
6
+ * into `AuthCredential.issue(userId, …)` at the token endpoint. Decided by the
7
+ * {@link import("./client-policy").ClientRedirectPolicy} (per client / scope),
8
+ * never by the client request, and recorded on the pending-authorization + the
9
+ * issued auth-code so the grant's authority is fixed at `/authorize` time, not
10
+ * `/token` time.
11
+ *
12
+ * Tier 1 (CLI / loopback) → a full-authority `cli-session` (`{ kind, ttl }`, no
13
+ * `payload`). A scoped service token (Tier 2) additionally sets `payload` with
14
+ * the consumer's attenuation root fields.
15
+ */
16
+ interface TokenPolicy {
17
+ /** Semantic credential kind stamped on the token (e.g. `"cli-session"`). See `IssueOptions.kind`. */
18
+ kind?: string;
19
+ /** Per-mint access-token lifetime in ms (forwarded to `IssueOptions.ttl`). */
20
+ ttl?: number;
21
+ /**
22
+ * Extra root payload merged into `issue()` — e.g. `@arbac.attenuate.*` fields
23
+ * for a SCOPED token. Omit for a full-authority token. MUST be JSON-safe: it
24
+ * is persisted on the pending-authorization + auth-code records.
25
+ */
26
+ payload?: Record<string, unknown>;
27
+ }
28
+ //#endregion
29
+ //#region src/authz/authz-errors.d.ts
30
+ /**
31
+ * Failure taxonomy for the authorization-server endpoints (AUTH-SERVER.md §7).
32
+ * The `/authorize` side fails SOFT (302 → an app error/login route, never a
33
+ * 500); the `/token` side returns the RFC-6749-shaped JSON error named by the
34
+ * code. Messages are benign — they must not disclose whether a failure was an
35
+ * unknown client vs. a bad redirect vs. an expired code.
36
+ */
37
+ type AuthorizeErrorCode = /** Missing or malformed parameter at `/authorize` or `/token`. */"invalid_request" /** `redirect_uri` is not an allowed (loopback / registered) target. */ | "invalid_redirect" /** Code unknown / expired / already-redeemed, or PKCE verifier mismatch (`/token`). */ | "invalid_grant" /** Unknown or unauthenticated client (Tier 2). */ | "invalid_client" /** The user declined the authorization (consent). */ | "access_denied" /** An unexpected server-side failure. */ | "server_error";
38
+ /** A typed authorization-server failure. */
39
+ declare class AuthorizeError extends Error {
40
+ readonly code: AuthorizeErrorCode;
41
+ constructor(code: AuthorizeErrorCode, message: string);
42
+ }
43
+ //#endregion
44
+ //#region src/authz/client-policy.d.ts
45
+ /**
46
+ * The client + redirect resolved at `GET /auth/authorize` — what the grant is
47
+ * allowed to deliver and where.
48
+ */
49
+ interface ResolvedClient {
50
+ /** Registered client id (Tier 2), absent for a public/loopback client. */
51
+ clientId?: string;
52
+ /** The validated `redirect_uri` the code will be delivered to. */
53
+ redirectUri: string;
54
+ /** What the grant mints (fixed here, recorded on the pending authorization). */
55
+ tokenPolicy: TokenPolicy;
56
+ }
57
+ /**
58
+ * The pluggable trust boundary of the authorization server (AUTH-SERVER.md §4.5):
59
+ * decide whether a client + `redirect_uri` may start a grant and what it may
60
+ * receive. The flow is otherwise identical across tiers — only this policy
61
+ * varies. Tier 1 ships {@link LoopbackClientPolicy}; a `RegisteredClientPolicy`
62
+ * (static client registry, exact/prefix redirect allowlist, `id_token`) is the
63
+ * Tier-2 addition.
64
+ */
65
+ interface ClientRedirectPolicy {
66
+ /**
67
+ * Authorize the client + `redirect_uri` and resolve the token policy; THROW
68
+ * an {@link AuthorizeError} on a miss (`invalid_redirect` / `invalid_client`).
69
+ * This is THE open-redirect / token-theft gate — never reflect an unvalidated
70
+ * `redirect_uri`.
71
+ */
72
+ resolveClient(args: {
73
+ clientId?: string;
74
+ redirectUri: string;
75
+ }): ResolvedClient | Promise<ResolvedClient>;
76
+ }
77
+ /**
78
+ * `true` when `uri` is a syntactically valid http(s) URL whose host is a
79
+ * **loopback literal** — `127.0.0.1`, `::1`, or `localhost` — on any port (RFC
80
+ * 8252 §7.3). Rejects everything else, including the classic bypasses: a
81
+ * host-suffix (`127.0.0.1.evil.com`, `localhost.evil.com`), embedded credentials
82
+ * (`http://127.0.0.1@evil.com` → host `evil.com`), a non-http scheme, and a bare
83
+ * `0.0.0.0`. Only a local process can receive a loopback redirect, which is why
84
+ * an arbitrary port is safe — the binding is the loopback host + PKCE.
85
+ */
86
+ declare function isLoopbackRedirectUri(uri: string): boolean;
87
+ interface LoopbackClientPolicyOptions {
88
+ /**
89
+ * What a loopback grant mints. Default: a full-authority `cli-session` with a
90
+ * 30-day TTL (un-attenuated — see {@link TokenPolicy}). Override to scope or
91
+ * re-label CLI tokens.
92
+ */
93
+ tokenPolicy?: TokenPolicy;
94
+ }
95
+ /**
96
+ * Tier-1 policy: accept any **loopback** `redirect_uri`, treat the client as a
97
+ * public client (no `client_id` / secret — PKCE is the binding), and mint the
98
+ * configured CLI token policy. Rejects every non-loopback redirect.
99
+ */
100
+ declare class LoopbackClientPolicy implements ClientRedirectPolicy {
101
+ private readonly tokenPolicy;
102
+ constructor(opts?: LoopbackClientPolicyOptions);
103
+ resolveClient(args: {
104
+ clientId?: string;
105
+ redirectUri: string;
106
+ }): ResolvedClient;
107
+ }
108
+ //#endregion
109
+ //#region src/authz/pending-authorization-store.d.ts
110
+ /**
111
+ * One in-flight authorization request, recorded at `GET /auth/authorize` and
112
+ * read once at the login-workflow terminal that mints the auth code. Keyed by an
113
+ * opaque `handle` that rides the login-wf ctx (and, across a "Continue with
114
+ * Google" detour, the federated signed `state`) — so nothing secret leaves the
115
+ * server.
116
+ */
117
+ interface PendingAuthorization {
118
+ /** Opaque server-side handle (the only thing that rides the URL / wf state). */
119
+ handle: string;
120
+ /** Registered client id (Tier 2), absent for a public/loopback client. */
121
+ clientId?: string;
122
+ /** The client's validated `redirect_uri` — where the code is delivered. */
123
+ redirectUri: string;
124
+ /** PKCE S256 challenge (client-generated); verified against the verifier at `/token`. */
125
+ codeChallenge: string;
126
+ /** The client's `state`, echoed back on the redirect so the client can correlate. */
127
+ clientState?: string;
128
+ /** Requested scope (space-joined), informational for Tier 1. */
129
+ scope?: string;
130
+ /** What the grant will mint (fixed at authorize time). */
131
+ tokenPolicy: TokenPolicy;
132
+ createdAt: number;
133
+ expiresAt: number;
134
+ }
135
+ /** Input to {@link PendingAuthorizationStore.create} — `handle`/timestamps are store-assigned. */
136
+ interface NewPendingAuthorization {
137
+ clientId?: string;
138
+ redirectUri: string;
139
+ codeChallenge: string;
140
+ clientState?: string;
141
+ scope?: string;
142
+ tokenPolicy: TokenPolicy;
143
+ }
144
+ /**
145
+ * Storage seam for in-flight authorizations (AUTH-SERVER.md §4.3). Short-lived
146
+ * (≈ the login-session ceiling): created at `/authorize`, read+deleted at the
147
+ * wf terminal. An in-memory impl ships for single-process apps + tests; a
148
+ * multi-pod deployment provides a durable (e.g. Redis) impl under the same
149
+ * `PENDING_AUTHORIZATION_STORE_TOKEN` (from `@aooth/auth-moost`).
150
+ */
151
+ declare abstract class PendingAuthorizationStore {
152
+ /** Record a new pending authorization; returns its opaque `handle`. */
153
+ abstract create(rec: NewPendingAuthorization): Promise<{
154
+ handle: string;
155
+ }>;
156
+ /** Fetch by handle, or `null` when unknown/expired. */
157
+ abstract get(handle: string): Promise<PendingAuthorization | null>;
158
+ /** Drop a handle once consumed. Returns `true` when a row was removed. */
159
+ abstract delete(handle: string): Promise<boolean>;
160
+ }
161
+ interface PendingAuthorizationStoreMemoryOptions {
162
+ /** Injectable clock for deterministic expiry. Defaults to {@link defaultClock}. */
163
+ clock?: Clock;
164
+ /** How long a pending authorization stays valid. Default 15 min. */
165
+ ttlMs?: number;
166
+ }
167
+ /**
168
+ * In-memory {@link PendingAuthorizationStore} — the reference impl for a
169
+ * single-process app + tests. `structuredClone` on read/write isolates callers.
170
+ */
171
+ declare class PendingAuthorizationStoreMemory extends PendingAuthorizationStore {
172
+ private store;
173
+ private clock;
174
+ private ttlMs;
175
+ constructor(opts?: PendingAuthorizationStoreMemoryOptions);
176
+ create(rec: NewPendingAuthorization): Promise<{
177
+ handle: string;
178
+ }>;
179
+ get(handle: string): Promise<PendingAuthorization | null>;
180
+ delete(handle: string): Promise<boolean>;
181
+ }
182
+ //#endregion
183
+ //#region src/authz/auth-code-store.d.ts
184
+ /**
185
+ * A minted, single-use authorization code, bound to the user the login resolved
186
+ * plus the PKCE challenge / redirect / token policy recorded at `/authorize`.
187
+ * Consumed atomically at `POST /auth/token` — the token is minted there, off the
188
+ * browser, so nothing long-lived ever rides a redirect URL.
189
+ */
190
+ interface AuthCode {
191
+ /** Opaque single-use code (delivered to the client in the redirect query). */
192
+ code: string;
193
+ /** The user the login workflow authenticated. */
194
+ userId: string;
195
+ /** PKCE S256 challenge from the originating authorize request. */
196
+ codeChallenge: string;
197
+ /** The client's `redirect_uri` (bound; the code is meaningless elsewhere). */
198
+ redirectUri: string;
199
+ /** Registered client id (Tier 2), absent for a public/loopback client. */
200
+ clientId?: string;
201
+ /** What `/token` mints when this code is redeemed. */
202
+ tokenPolicy: TokenPolicy;
203
+ expiresAt: number;
204
+ }
205
+ /** Input to {@link AuthCodeStore.mint} — `code`/`expiresAt` are store-assigned. */
206
+ interface NewAuthCode {
207
+ userId: string;
208
+ codeChallenge: string;
209
+ redirectUri: string;
210
+ clientId?: string;
211
+ tokenPolicy: TokenPolicy;
212
+ }
213
+ /**
214
+ * Storage seam for issued authorization codes (AUTH-SERVER.md §4.3). Very
215
+ * short-lived (≈ 30–60 s) and **single-use**: {@link consume} returns the row
216
+ * AND invalidates it in one atomic step, so a concurrent double-redeem (or a
217
+ * back-button replay) yields the code to exactly one caller. An in-memory impl
218
+ * ships (atomic for free in single-threaded JS); a durable impl must implement
219
+ * `consume` as an atomic check-and-delete (e.g. `withCas` / `@db.column.version`,
220
+ * or a Redis `GETDEL`).
221
+ */
222
+ declare abstract class AuthCodeStore {
223
+ /** Mint + store a single-use code; returns the opaque code string. */
224
+ abstract mint(rec: NewAuthCode): Promise<{
225
+ code: string;
226
+ }>;
227
+ /** Atomically claim + return the code's row, or `null` on miss / reuse / expiry. */
228
+ abstract consume(code: string): Promise<AuthCode | null>;
229
+ }
230
+ interface AuthCodeStoreMemoryOptions {
231
+ /** Injectable clock for deterministic expiry. Defaults to {@link defaultClock}. */
232
+ clock?: Clock;
233
+ /** Code lifetime. Default 60 s. */
234
+ ttlMs?: number;
235
+ }
236
+ /**
237
+ * In-memory {@link AuthCodeStore} — the reference impl. `consume` is atomic
238
+ * because the `get` + `delete` run with no intervening `await`, so a second
239
+ * concurrent `consume` of the same code always misses.
240
+ */
241
+ declare class AuthCodeStoreMemory extends AuthCodeStore {
242
+ private store;
243
+ private clock;
244
+ private ttlMs;
245
+ constructor(opts?: AuthCodeStoreMemoryOptions);
246
+ mint(rec: NewAuthCode): Promise<{
247
+ code: string;
248
+ }>;
249
+ consume(code: string): Promise<AuthCode | null>;
250
+ }
251
+ //#endregion
252
+ export { type AuthCode, AuthCodeStore, AuthCodeStoreMemory, type AuthCodeStoreMemoryOptions, AuthorizeError, type AuthorizeErrorCode, type ClientRedirectPolicy, LoopbackClientPolicy, type LoopbackClientPolicyOptions, type NewAuthCode, type NewPendingAuthorization, type PendingAuthorization, PendingAuthorizationStore, PendingAuthorizationStoreMemory, type PendingAuthorizationStoreMemoryOptions, type ResolvedClient, type TokenPolicy, isLoopbackRedirectUri };
@@ -0,0 +1,252 @@
1
+ import { t as Clock } from "./clock-BjXa0LXb.mjs";
2
+
3
+ //#region src/authz/token-policy.d.ts
4
+ /**
5
+ * What the authorization server mints for a completed grant — forwarded verbatim
6
+ * into `AuthCredential.issue(userId, …)` at the token endpoint. Decided by the
7
+ * {@link import("./client-policy").ClientRedirectPolicy} (per client / scope),
8
+ * never by the client request, and recorded on the pending-authorization + the
9
+ * issued auth-code so the grant's authority is fixed at `/authorize` time, not
10
+ * `/token` time.
11
+ *
12
+ * Tier 1 (CLI / loopback) → a full-authority `cli-session` (`{ kind, ttl }`, no
13
+ * `payload`). A scoped service token (Tier 2) additionally sets `payload` with
14
+ * the consumer's attenuation root fields.
15
+ */
16
+ interface TokenPolicy {
17
+ /** Semantic credential kind stamped on the token (e.g. `"cli-session"`). See `IssueOptions.kind`. */
18
+ kind?: string;
19
+ /** Per-mint access-token lifetime in ms (forwarded to `IssueOptions.ttl`). */
20
+ ttl?: number;
21
+ /**
22
+ * Extra root payload merged into `issue()` — e.g. `@arbac.attenuate.*` fields
23
+ * for a SCOPED token. Omit for a full-authority token. MUST be JSON-safe: it
24
+ * is persisted on the pending-authorization + auth-code records.
25
+ */
26
+ payload?: Record<string, unknown>;
27
+ }
28
+ //#endregion
29
+ //#region src/authz/authz-errors.d.ts
30
+ /**
31
+ * Failure taxonomy for the authorization-server endpoints (AUTH-SERVER.md §7).
32
+ * The `/authorize` side fails SOFT (302 → an app error/login route, never a
33
+ * 500); the `/token` side returns the RFC-6749-shaped JSON error named by the
34
+ * code. Messages are benign — they must not disclose whether a failure was an
35
+ * unknown client vs. a bad redirect vs. an expired code.
36
+ */
37
+ type AuthorizeErrorCode = /** Missing or malformed parameter at `/authorize` or `/token`. */"invalid_request" /** `redirect_uri` is not an allowed (loopback / registered) target. */ | "invalid_redirect" /** Code unknown / expired / already-redeemed, or PKCE verifier mismatch (`/token`). */ | "invalid_grant" /** Unknown or unauthenticated client (Tier 2). */ | "invalid_client" /** The user declined the authorization (consent). */ | "access_denied" /** An unexpected server-side failure. */ | "server_error";
38
+ /** A typed authorization-server failure. */
39
+ declare class AuthorizeError extends Error {
40
+ readonly code: AuthorizeErrorCode;
41
+ constructor(code: AuthorizeErrorCode, message: string);
42
+ }
43
+ //#endregion
44
+ //#region src/authz/client-policy.d.ts
45
+ /**
46
+ * The client + redirect resolved at `GET /auth/authorize` — what the grant is
47
+ * allowed to deliver and where.
48
+ */
49
+ interface ResolvedClient {
50
+ /** Registered client id (Tier 2), absent for a public/loopback client. */
51
+ clientId?: string;
52
+ /** The validated `redirect_uri` the code will be delivered to. */
53
+ redirectUri: string;
54
+ /** What the grant mints (fixed here, recorded on the pending authorization). */
55
+ tokenPolicy: TokenPolicy;
56
+ }
57
+ /**
58
+ * The pluggable trust boundary of the authorization server (AUTH-SERVER.md §4.5):
59
+ * decide whether a client + `redirect_uri` may start a grant and what it may
60
+ * receive. The flow is otherwise identical across tiers — only this policy
61
+ * varies. Tier 1 ships {@link LoopbackClientPolicy}; a `RegisteredClientPolicy`
62
+ * (static client registry, exact/prefix redirect allowlist, `id_token`) is the
63
+ * Tier-2 addition.
64
+ */
65
+ interface ClientRedirectPolicy {
66
+ /**
67
+ * Authorize the client + `redirect_uri` and resolve the token policy; THROW
68
+ * an {@link AuthorizeError} on a miss (`invalid_redirect` / `invalid_client`).
69
+ * This is THE open-redirect / token-theft gate — never reflect an unvalidated
70
+ * `redirect_uri`.
71
+ */
72
+ resolveClient(args: {
73
+ clientId?: string;
74
+ redirectUri: string;
75
+ }): ResolvedClient | Promise<ResolvedClient>;
76
+ }
77
+ /**
78
+ * `true` when `uri` is a syntactically valid http(s) URL whose host is a
79
+ * **loopback literal** — `127.0.0.1`, `::1`, or `localhost` — on any port (RFC
80
+ * 8252 §7.3). Rejects everything else, including the classic bypasses: a
81
+ * host-suffix (`127.0.0.1.evil.com`, `localhost.evil.com`), embedded credentials
82
+ * (`http://127.0.0.1@evil.com` → host `evil.com`), a non-http scheme, and a bare
83
+ * `0.0.0.0`. Only a local process can receive a loopback redirect, which is why
84
+ * an arbitrary port is safe — the binding is the loopback host + PKCE.
85
+ */
86
+ declare function isLoopbackRedirectUri(uri: string): boolean;
87
+ interface LoopbackClientPolicyOptions {
88
+ /**
89
+ * What a loopback grant mints. Default: a full-authority `cli-session` with a
90
+ * 30-day TTL (un-attenuated — see {@link TokenPolicy}). Override to scope or
91
+ * re-label CLI tokens.
92
+ */
93
+ tokenPolicy?: TokenPolicy;
94
+ }
95
+ /**
96
+ * Tier-1 policy: accept any **loopback** `redirect_uri`, treat the client as a
97
+ * public client (no `client_id` / secret — PKCE is the binding), and mint the
98
+ * configured CLI token policy. Rejects every non-loopback redirect.
99
+ */
100
+ declare class LoopbackClientPolicy implements ClientRedirectPolicy {
101
+ private readonly tokenPolicy;
102
+ constructor(opts?: LoopbackClientPolicyOptions);
103
+ resolveClient(args: {
104
+ clientId?: string;
105
+ redirectUri: string;
106
+ }): ResolvedClient;
107
+ }
108
+ //#endregion
109
+ //#region src/authz/pending-authorization-store.d.ts
110
+ /**
111
+ * One in-flight authorization request, recorded at `GET /auth/authorize` and
112
+ * read once at the login-workflow terminal that mints the auth code. Keyed by an
113
+ * opaque `handle` that rides the login-wf ctx (and, across a "Continue with
114
+ * Google" detour, the federated signed `state`) — so nothing secret leaves the
115
+ * server.
116
+ */
117
+ interface PendingAuthorization {
118
+ /** Opaque server-side handle (the only thing that rides the URL / wf state). */
119
+ handle: string;
120
+ /** Registered client id (Tier 2), absent for a public/loopback client. */
121
+ clientId?: string;
122
+ /** The client's validated `redirect_uri` — where the code is delivered. */
123
+ redirectUri: string;
124
+ /** PKCE S256 challenge (client-generated); verified against the verifier at `/token`. */
125
+ codeChallenge: string;
126
+ /** The client's `state`, echoed back on the redirect so the client can correlate. */
127
+ clientState?: string;
128
+ /** Requested scope (space-joined), informational for Tier 1. */
129
+ scope?: string;
130
+ /** What the grant will mint (fixed at authorize time). */
131
+ tokenPolicy: TokenPolicy;
132
+ createdAt: number;
133
+ expiresAt: number;
134
+ }
135
+ /** Input to {@link PendingAuthorizationStore.create} — `handle`/timestamps are store-assigned. */
136
+ interface NewPendingAuthorization {
137
+ clientId?: string;
138
+ redirectUri: string;
139
+ codeChallenge: string;
140
+ clientState?: string;
141
+ scope?: string;
142
+ tokenPolicy: TokenPolicy;
143
+ }
144
+ /**
145
+ * Storage seam for in-flight authorizations (AUTH-SERVER.md §4.3). Short-lived
146
+ * (≈ the login-session ceiling): created at `/authorize`, read+deleted at the
147
+ * wf terminal. An in-memory impl ships for single-process apps + tests; a
148
+ * multi-pod deployment provides a durable (e.g. Redis) impl under the same
149
+ * `PENDING_AUTHORIZATION_STORE_TOKEN` (from `@aooth/auth-moost`).
150
+ */
151
+ declare abstract class PendingAuthorizationStore {
152
+ /** Record a new pending authorization; returns its opaque `handle`. */
153
+ abstract create(rec: NewPendingAuthorization): Promise<{
154
+ handle: string;
155
+ }>;
156
+ /** Fetch by handle, or `null` when unknown/expired. */
157
+ abstract get(handle: string): Promise<PendingAuthorization | null>;
158
+ /** Drop a handle once consumed. Returns `true` when a row was removed. */
159
+ abstract delete(handle: string): Promise<boolean>;
160
+ }
161
+ interface PendingAuthorizationStoreMemoryOptions {
162
+ /** Injectable clock for deterministic expiry. Defaults to {@link defaultClock}. */
163
+ clock?: Clock;
164
+ /** How long a pending authorization stays valid. Default 15 min. */
165
+ ttlMs?: number;
166
+ }
167
+ /**
168
+ * In-memory {@link PendingAuthorizationStore} — the reference impl for a
169
+ * single-process app + tests. `structuredClone` on read/write isolates callers.
170
+ */
171
+ declare class PendingAuthorizationStoreMemory extends PendingAuthorizationStore {
172
+ private store;
173
+ private clock;
174
+ private ttlMs;
175
+ constructor(opts?: PendingAuthorizationStoreMemoryOptions);
176
+ create(rec: NewPendingAuthorization): Promise<{
177
+ handle: string;
178
+ }>;
179
+ get(handle: string): Promise<PendingAuthorization | null>;
180
+ delete(handle: string): Promise<boolean>;
181
+ }
182
+ //#endregion
183
+ //#region src/authz/auth-code-store.d.ts
184
+ /**
185
+ * A minted, single-use authorization code, bound to the user the login resolved
186
+ * plus the PKCE challenge / redirect / token policy recorded at `/authorize`.
187
+ * Consumed atomically at `POST /auth/token` — the token is minted there, off the
188
+ * browser, so nothing long-lived ever rides a redirect URL.
189
+ */
190
+ interface AuthCode {
191
+ /** Opaque single-use code (delivered to the client in the redirect query). */
192
+ code: string;
193
+ /** The user the login workflow authenticated. */
194
+ userId: string;
195
+ /** PKCE S256 challenge from the originating authorize request. */
196
+ codeChallenge: string;
197
+ /** The client's `redirect_uri` (bound; the code is meaningless elsewhere). */
198
+ redirectUri: string;
199
+ /** Registered client id (Tier 2), absent for a public/loopback client. */
200
+ clientId?: string;
201
+ /** What `/token` mints when this code is redeemed. */
202
+ tokenPolicy: TokenPolicy;
203
+ expiresAt: number;
204
+ }
205
+ /** Input to {@link AuthCodeStore.mint} — `code`/`expiresAt` are store-assigned. */
206
+ interface NewAuthCode {
207
+ userId: string;
208
+ codeChallenge: string;
209
+ redirectUri: string;
210
+ clientId?: string;
211
+ tokenPolicy: TokenPolicy;
212
+ }
213
+ /**
214
+ * Storage seam for issued authorization codes (AUTH-SERVER.md §4.3). Very
215
+ * short-lived (≈ 30–60 s) and **single-use**: {@link consume} returns the row
216
+ * AND invalidates it in one atomic step, so a concurrent double-redeem (or a
217
+ * back-button replay) yields the code to exactly one caller. An in-memory impl
218
+ * ships (atomic for free in single-threaded JS); a durable impl must implement
219
+ * `consume` as an atomic check-and-delete (e.g. `withCas` / `@db.column.version`,
220
+ * or a Redis `GETDEL`).
221
+ */
222
+ declare abstract class AuthCodeStore {
223
+ /** Mint + store a single-use code; returns the opaque code string. */
224
+ abstract mint(rec: NewAuthCode): Promise<{
225
+ code: string;
226
+ }>;
227
+ /** Atomically claim + return the code's row, or `null` on miss / reuse / expiry. */
228
+ abstract consume(code: string): Promise<AuthCode | null>;
229
+ }
230
+ interface AuthCodeStoreMemoryOptions {
231
+ /** Injectable clock for deterministic expiry. Defaults to {@link defaultClock}. */
232
+ clock?: Clock;
233
+ /** Code lifetime. Default 60 s. */
234
+ ttlMs?: number;
235
+ }
236
+ /**
237
+ * In-memory {@link AuthCodeStore} — the reference impl. `consume` is atomic
238
+ * because the `get` + `delete` run with no intervening `await`, so a second
239
+ * concurrent `consume` of the same code always misses.
240
+ */
241
+ declare class AuthCodeStoreMemory extends AuthCodeStore {
242
+ private store;
243
+ private clock;
244
+ private ttlMs;
245
+ constructor(opts?: AuthCodeStoreMemoryOptions);
246
+ mint(rec: NewAuthCode): Promise<{
247
+ code: string;
248
+ }>;
249
+ consume(code: string): Promise<AuthCode | null>;
250
+ }
251
+ //#endregion
252
+ export { type AuthCode, AuthCodeStore, AuthCodeStoreMemory, type AuthCodeStoreMemoryOptions, AuthorizeError, type AuthorizeErrorCode, type ClientRedirectPolicy, LoopbackClientPolicy, type LoopbackClientPolicyOptions, type NewAuthCode, type NewPendingAuthorization, type PendingAuthorization, PendingAuthorizationStore, PendingAuthorizationStoreMemory, type PendingAuthorizationStoreMemoryOptions, type ResolvedClient, type TokenPolicy, isLoopbackRedirectUri };
package/dist/authz.mjs ADDED
@@ -0,0 +1,161 @@
1
+ import { t as defaultClock } from "./clock-Bdsep_1j.mjs";
2
+ import { randomUUID } from "node:crypto";
3
+ //#region src/authz/authz-errors.ts
4
+ /** A typed authorization-server failure. */
5
+ var AuthorizeError = class extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.name = "AuthorizeError";
10
+ this.code = code;
11
+ }
12
+ };
13
+ //#endregion
14
+ //#region src/authz/client-policy.ts
15
+ /**
16
+ * `true` when `uri` is a syntactically valid http(s) URL whose host is a
17
+ * **loopback literal** — `127.0.0.1`, `::1`, or `localhost` — on any port (RFC
18
+ * 8252 §7.3). Rejects everything else, including the classic bypasses: a
19
+ * host-suffix (`127.0.0.1.evil.com`, `localhost.evil.com`), embedded credentials
20
+ * (`http://127.0.0.1@evil.com` → host `evil.com`), a non-http scheme, and a bare
21
+ * `0.0.0.0`. Only a local process can receive a loopback redirect, which is why
22
+ * an arbitrary port is safe — the binding is the loopback host + PKCE.
23
+ */
24
+ function isLoopbackRedirectUri(uri) {
25
+ let url;
26
+ try {
27
+ url = new URL(uri);
28
+ } catch {
29
+ return false;
30
+ }
31
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
32
+ if (url.username !== "" || url.password !== "") return false;
33
+ const host = url.hostname.replace(/^\[|\]$/g, "");
34
+ return host === "127.0.0.1" || host === "::1" || host === "localhost";
35
+ }
36
+ const DEFAULT_CLI_TOKEN_POLICY = {
37
+ kind: "cli-session",
38
+ ttl: 720 * 60 * 6e4
39
+ };
40
+ /**
41
+ * Tier-1 policy: accept any **loopback** `redirect_uri`, treat the client as a
42
+ * public client (no `client_id` / secret — PKCE is the binding), and mint the
43
+ * configured CLI token policy. Rejects every non-loopback redirect.
44
+ */
45
+ var LoopbackClientPolicy = class {
46
+ tokenPolicy;
47
+ constructor(opts) {
48
+ this.tokenPolicy = opts?.tokenPolicy ?? DEFAULT_CLI_TOKEN_POLICY;
49
+ }
50
+ resolveClient(args) {
51
+ if (!isLoopbackRedirectUri(args.redirectUri)) throw new AuthorizeError("invalid_redirect", "redirect_uri must be a loopback address");
52
+ return {
53
+ redirectUri: args.redirectUri,
54
+ tokenPolicy: structuredClone(this.tokenPolicy)
55
+ };
56
+ }
57
+ };
58
+ //#endregion
59
+ //#region src/authz/pending-authorization-store.ts
60
+ /**
61
+ * Storage seam for in-flight authorizations (AUTH-SERVER.md §4.3). Short-lived
62
+ * (≈ the login-session ceiling): created at `/authorize`, read+deleted at the
63
+ * wf terminal. An in-memory impl ships for single-process apps + tests; a
64
+ * multi-pod deployment provides a durable (e.g. Redis) impl under the same
65
+ * `PENDING_AUTHORIZATION_STORE_TOKEN` (from `@aooth/auth-moost`).
66
+ */
67
+ var PendingAuthorizationStore = class {};
68
+ const DEFAULT_PENDING_TTL_MS = 15 * 6e4;
69
+ /**
70
+ * In-memory {@link PendingAuthorizationStore} — the reference impl for a
71
+ * single-process app + tests. `structuredClone` on read/write isolates callers.
72
+ */
73
+ var PendingAuthorizationStoreMemory = class extends PendingAuthorizationStore {
74
+ store = /* @__PURE__ */ new Map();
75
+ clock;
76
+ ttlMs;
77
+ constructor(opts) {
78
+ super();
79
+ this.clock = opts?.clock ?? defaultClock;
80
+ this.ttlMs = opts?.ttlMs ?? DEFAULT_PENDING_TTL_MS;
81
+ }
82
+ async create(rec) {
83
+ const now = this.clock.now();
84
+ const row = {
85
+ handle: randomUUID(),
86
+ redirectUri: rec.redirectUri,
87
+ codeChallenge: rec.codeChallenge,
88
+ tokenPolicy: structuredClone(rec.tokenPolicy),
89
+ createdAt: now,
90
+ expiresAt: now + this.ttlMs,
91
+ ...rec.clientId !== void 0 && { clientId: rec.clientId },
92
+ ...rec.clientState !== void 0 && { clientState: rec.clientState },
93
+ ...rec.scope !== void 0 && { scope: rec.scope }
94
+ };
95
+ this.store.set(row.handle, structuredClone(row));
96
+ return { handle: row.handle };
97
+ }
98
+ async get(handle) {
99
+ const row = this.store.get(handle);
100
+ if (!row) return null;
101
+ if (row.expiresAt <= this.clock.now()) {
102
+ this.store.delete(handle);
103
+ return null;
104
+ }
105
+ return structuredClone(row);
106
+ }
107
+ async delete(handle) {
108
+ return this.store.delete(handle);
109
+ }
110
+ };
111
+ //#endregion
112
+ //#region src/authz/auth-code-store.ts
113
+ /**
114
+ * Storage seam for issued authorization codes (AUTH-SERVER.md §4.3). Very
115
+ * short-lived (≈ 30–60 s) and **single-use**: {@link consume} returns the row
116
+ * AND invalidates it in one atomic step, so a concurrent double-redeem (or a
117
+ * back-button replay) yields the code to exactly one caller. An in-memory impl
118
+ * ships (atomic for free in single-threaded JS); a durable impl must implement
119
+ * `consume` as an atomic check-and-delete (e.g. `withCas` / `@db.column.version`,
120
+ * or a Redis `GETDEL`).
121
+ */
122
+ var AuthCodeStore = class {};
123
+ const DEFAULT_CODE_TTL_MS = 6e4;
124
+ /**
125
+ * In-memory {@link AuthCodeStore} — the reference impl. `consume` is atomic
126
+ * because the `get` + `delete` run with no intervening `await`, so a second
127
+ * concurrent `consume` of the same code always misses.
128
+ */
129
+ var AuthCodeStoreMemory = class extends AuthCodeStore {
130
+ store = /* @__PURE__ */ new Map();
131
+ clock;
132
+ ttlMs;
133
+ constructor(opts) {
134
+ super();
135
+ this.clock = opts?.clock ?? defaultClock;
136
+ this.ttlMs = opts?.ttlMs ?? DEFAULT_CODE_TTL_MS;
137
+ }
138
+ async mint(rec) {
139
+ const code = randomUUID();
140
+ const row = {
141
+ code,
142
+ userId: rec.userId,
143
+ codeChallenge: rec.codeChallenge,
144
+ redirectUri: rec.redirectUri,
145
+ tokenPolicy: structuredClone(rec.tokenPolicy),
146
+ expiresAt: this.clock.now() + this.ttlMs,
147
+ ...rec.clientId !== void 0 && { clientId: rec.clientId }
148
+ };
149
+ this.store.set(code, structuredClone(row));
150
+ return { code };
151
+ }
152
+ async consume(code) {
153
+ const row = this.store.get(code);
154
+ if (!row) return null;
155
+ this.store.delete(code);
156
+ if (row.expiresAt <= this.clock.now()) return null;
157
+ return structuredClone(row);
158
+ }
159
+ };
160
+ //#endregion
161
+ export { AuthCodeStore, AuthCodeStoreMemory, AuthorizeError, LoopbackClientPolicy, PendingAuthorizationStore, PendingAuthorizationStoreMemory, isLoopbackRedirectUri };