@aooth/auth 0.1.6 → 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.
- package/dist/atscript-db.cjs +36 -25
- package/dist/atscript-db.d.cts +28 -22
- package/dist/atscript-db.d.mts +28 -22
- package/dist/atscript-db.mjs +36 -25
- package/dist/authz.cjs +168 -0
- package/dist/authz.d.cts +252 -0
- package/dist/authz.d.mts +252 -0
- package/dist/authz.mjs +161 -0
- package/dist/client.cjs +59 -0
- package/dist/client.d.cts +64 -0
- package/dist/client.d.mts +64 -0
- package/dist/client.mjs +58 -0
- package/dist/clock-Bdsep_1j.mjs +4 -0
- package/dist/clock-BjXa0LXb.d.cts +14 -0
- package/dist/clock-BjXa0LXb.d.mts +14 -0
- package/dist/clock-Bl-H3eqE.cjs +9 -0
- package/dist/index.cjs +294 -65
- package/dist/index.d.cts +166 -44
- package/dist/index.d.mts +166 -44
- package/dist/index.mjs +289 -60
- package/dist/payload-BJjvj8AH.cjs +32 -0
- package/dist/payload-D-DzH5-J.mjs +27 -0
- package/dist/redis.cjs +9 -0
- package/dist/redis.d.cts +8 -7
- package/dist/redis.d.mts +8 -7
- package/dist/redis.mjs +9 -0
- package/dist/store-BG6m6oSJ.d.cts +263 -0
- package/dist/store-BG6m6oSJ.d.mts +263 -0
- package/package.json +28 -10
- package/src/atscript-db/auth-credential.as +16 -10
- package/src/atscript-db/auth-credential.as.d.ts +68 -0
- package/dist/store-B1t8KkfA.d.mts +0 -124
- package/dist/store-untAtWQz.d.cts +0 -124
package/dist/authz.d.cts
ADDED
|
@@ -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 };
|
package/dist/authz.d.mts
ADDED
|
@@ -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 };
|