@vantageos/cloud-identity 0.6.0 → 0.8.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +147 -0
  3. package/dist/authorize-flow.d.ts +169 -0
  4. package/dist/authorize-flow.d.ts.map +1 -0
  5. package/dist/authorize-flow.js +262 -0
  6. package/dist/authorize-flow.js.map +1 -0
  7. package/dist/authorize-shared.d.ts +44 -0
  8. package/dist/authorize-shared.d.ts.map +1 -0
  9. package/dist/authorize-shared.js +134 -0
  10. package/dist/authorize-shared.js.map +1 -0
  11. package/dist/clerk-session.d.ts +52 -0
  12. package/dist/clerk-session.d.ts.map +1 -0
  13. package/dist/clerk-session.js +93 -0
  14. package/dist/clerk-session.js.map +1 -0
  15. package/dist/crypto.d.ts +12 -0
  16. package/dist/crypto.d.ts.map +1 -1
  17. package/dist/crypto.js +18 -0
  18. package/dist/crypto.js.map +1 -1
  19. package/dist/human-path.d.ts +1 -1
  20. package/dist/index.d.ts +13 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +12 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/oidc.d.ts +76 -0
  25. package/dist/oidc.d.ts.map +1 -0
  26. package/dist/oidc.js +83 -0
  27. package/dist/oidc.js.map +1 -0
  28. package/dist/presented-bearer.d.ts +205 -0
  29. package/dist/presented-bearer.d.ts.map +1 -0
  30. package/dist/presented-bearer.js +226 -0
  31. package/dist/presented-bearer.js.map +1 -0
  32. package/dist/tenancy-domain.d.ts +6 -3
  33. package/dist/tenancy-domain.d.ts.map +1 -1
  34. package/dist/tenancy-domain.js +6 -3
  35. package/dist/tenancy-domain.js.map +1 -1
  36. package/dist/token-exchange.d.ts +53 -0
  37. package/dist/token-exchange.d.ts.map +1 -0
  38. package/dist/token-exchange.js +60 -0
  39. package/dist/token-exchange.js.map +1 -0
  40. package/package.json +21 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,127 @@
3
3
  All notable changes to `@vantageos/cloud-identity` are documented here.
4
4
  This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [Unreleased]
7
+
8
+ **Additive — authorize a PERSON and bind an organisation (proposed 0.8.0).**
9
+ A connector's `/authorize` that auto-approves with no user authentication, and
10
+ derives the code's user from a client-registration profile, makes every client
11
+ an anonymous app. This release is the flow that replaces it: Clerk session,
12
+ verified organisation membership, a code bound to both, and an exchange that
13
+ re-derives nothing from the client's profile.
14
+
15
+ ### Added
16
+
17
+ - `startAuthorize` / `resumeAuthorize` (`src/authorize-flow.ts`): request
18
+ validation (exact `redirect_uri`, PKCE challenge required, allowed `resource`,
19
+ supported scopes); no Clerk session -> redirect to sign-in with an
20
+ HMAC-signed, short-lived state blob and no code; verified session -> one
21
+ organisation auto-picked, several -> an `OrgPickerModel`, a posted `orgId`
22
+ honoured only if it is in the user's own membership list; `requireConsent`
23
+ (default ON; opt out explicitly for first-party clients only). The code is single-use, short-lived and stored as a digest
24
+ through a consumer-supplied `AuthorizationCodeStore` whose `consume` must be
25
+ atomic.
26
+ - `exchangeAuthorizationCode` (`src/token-exchange.ts`): consumes the code
27
+ first, then checks expiry, client, `redirect_uri`, `resource` and the PKCE
28
+ verifier; returns `{ sub, org_id, org_slug, org_role, aud, client_id, scope }`.
29
+ - `verifyClerkSessionToken` (`src/clerk-session.ts`): RS256 against a
30
+ consumer-supplied key set; issuer, expiry, `nbf`, optional audience and
31
+ authorized parties.
32
+ - `buildDiscoveryDocument`, `buildUserInfo` (`src/oidc.ts`). The document
33
+ advertises only what is implemented (no id_token alg, no refresh grant, no
34
+ client-auth methods).
35
+ - `AuthorizeRefusal` / `AuthorizeRefusalReason` (`{ code: "AUTHORIZE_REFUSED",
36
+ reason }`), `oauthErrorFor`, `pkceChallengeFromVerifier`, and the types listed
37
+ in the README section "Authorize a person and bind an organisation".
38
+ - Subpath exports `/authorize`, `/token-exchange`, `/clerk-session`, `/oidc`.
39
+
40
+ ### Security
41
+
42
+ - Consent is bound to a server-issued `consentToken` (HMAC over state, user and
43
+ organisation set). `approved: true` without a token valid for that state and
44
+ that session's user is refused with `consent-required`; a client can no
45
+ longer mint its own state and approve on a victim's behalf.
46
+
47
+ ### Changed
48
+
49
+ - README: the "does not verify a signed JWT" limit now names the one exception.
50
+ - `package.json` version set to 0.8.0 (proposed; not published).
51
+
52
+ ### Unchanged
53
+
54
+ - Every existing export keeps its behaviour and signature; no existing test
55
+ was edited. No new runtime dependency (Web Crypto only).
56
+
57
+ ## [0.7.0]
58
+
59
+ **Additive — resolution of a PRESENTED, non-master bearer.** The published
60
+ 0.5.0 surface covered the master credential (`validateMasterBearer`) and a
61
+ decoder that verifies nothing and says so (`decodeUnverifiedBearer`). Between
62
+ them there was no function taking a presented non-master bearer, hashing it,
63
+ looking the row up and returning a resolved identity or a typed refusal — so a
64
+ consuming product wrote that primitive locally, against an untyped client, with
65
+ the lookup failure swallowed into `null`. That is a second authority answering
66
+ "who is calling". This release is the single one, so the local variant can be
67
+ deleted rather than duplicated.
68
+
69
+ **Version number.** This entry takes `0.7.0`, not `0.6.0`. An unmerged branch
70
+ already claims `0.6.0` for `resolveTenantIdOrAbsent` (the non-throwing sibling
71
+ of `requireTenantId`); leaving `0.6.0` to it means these two can land in either
72
+ order without either having to be renumbered after review.
73
+
74
+ ### Added
75
+
76
+ - `validatePresentedBearer(authHeader, deps)` (`src/presented-bearer.ts`) —
77
+ parses the header, hashes the token to a SHA-256 hex digest, obtains the row
78
+ through the caller-supplied `deps.lookupBySecretHash(digest)`, re-compares the
79
+ row's stored digest with `timingSafeEqual`, then honours row shape, revocation
80
+ and expiry. The package cannot query a datastore, so the read is injected; every
81
+ decision stays in the package. `deps.now` is an injectable clock.
82
+ - **The callback receives the DIGEST, never the token**, keeping a usable
83
+ secret out of a consumer's query and out of its query logs.
84
+ - **An unknown digest, a revoked row, an expired row, a row of invalid shape
85
+ and a row whose stored digest does not match all return ONE shared frozen
86
+ value**, `{ ok: false, error: "mismatch" }` — one object rather than five
87
+ equal literals, so the branches cannot drift apart by an added field. A
88
+ prober cannot learn whether a token was ever real. `deps.onRefusal` reports
89
+ the internal distinction (`PresentedBearerInternalReason`) to the consumer's
90
+ own logs; a throwing sink cannot change the outcome.
91
+ - **A lookup that throws is a REFUSAL** (`error: "unavailable"`), deliberately
92
+ outside the collapsed bucket so a datastore outage can be answered 503
93
+ rather than reported to every caller as a bad credential. No branch grants
94
+ anything on a lookup failure.
95
+ - Header vocabulary is `validateMasterBearer`'s, unchanged: `"missing"`,
96
+ `"malformed"`, `"mismatch"`. A malformed header costs no datastore read.
97
+ - `requireAgentScopedIdentity(identity)` — the second, mandatory call for a
98
+ per-agent surface. An organization-wide token (a row with no agent identity)
99
+ resolves successfully and carries its agent as an EXPLICIT absence
100
+ (`AgentPresence`, a union that must be narrowed — not a nullable field a
101
+ caller can fail to check). The per-agent form `AgentScopedIdentity` carries a
102
+ module-private type brand, so it cannot be constructed outside the package: a
103
+ handler typed against it is reachable only through this function. Its refusal
104
+ is distinguishable from a bad credential on purpose — 403, not 401.
105
+ - `sha256Hex(input)` (`src/crypto.ts`) — lower-case hex SHA-256 of a string,
106
+ exported so the code WRITING a token row derives the digest exactly as the
107
+ resolver recomputes it.
108
+ - `storedBearerRowSchema` + `StoredBearerRow`, `AgentPresence`,
109
+ `ResolvedBearerIdentity`, `AgentScopedIdentity`,
110
+ `ValidatePresentedBearerResult`, `RequireAgentScopedResult`,
111
+ `PresentedBearerDeps`, `PresentedBearerInternalReason`.
112
+ - Subpath export `@vantageos/cloud-identity/presented-bearer`.
113
+
114
+ ### Changed
115
+
116
+ - Documentation only: `decodeUnverifiedBearer`'s security banner said the
117
+ production trust boundary was "not yet exported by this package". For an
118
+ opaque token it now is, and the banner names it. No behaviour change.
119
+
120
+ ### Unchanged
121
+
122
+ - **Every existing export keeps its exact behaviour and signature.** The new
123
+ module is a new file; the only edits to existing sources are one appended
124
+ function in `src/crypto.ts`, new export lines in `src/index.ts`, and the
125
+ docstring above. No existing test was edited.
126
+
6
127
  ## [0.6.0]
7
128
 
8
129
  **Additive — typed refusal for reads, and a role assertion.**
package/README.md CHANGED
@@ -129,6 +129,48 @@ await resolveTenant({ session: null }); // throws Error("Unauthenticated: no ses
129
129
  - `getEffectiveTenantId(ctx, args)` — resolves which tenant a request should
130
130
  act on when a caller may legitimately act on more than one.
131
131
 
132
+ **Resolving a presented bearer token**
133
+
134
+ - `validatePresentedBearer(authHeader, deps)` — takes the raw
135
+ `Authorization` header a non-master caller presented, and returns either a
136
+ resolved identity or a coarse refusal. It hashes the token, asks your
137
+ `deps.lookupBySecretHash` callback for the row carrying that digest,
138
+ re-compares the stored digest in constant time, and then checks revocation
139
+ and expiry. Your callback does one indexed read; every decision stays in the
140
+ package, so two consumers cannot drift into two different answers to "who is
141
+ calling".
142
+ - **Your callback receives the digest, never the token.** A raw secret
143
+ therefore never reaches your query, your query log or your slow-query log.
144
+ Use `sha256Hex` to produce the same digest when you WRITE a token row, so
145
+ the two sides agree.
146
+ - **An unknown token, a revoked token and an expired token return one
147
+ identical value** (`{ ok: false, error: "mismatch" }`). Someone probing
148
+ your endpoint cannot learn from the reply whether a token was ever real.
149
+ Pass `deps.onRefusal` to receive the distinction
150
+ (`PresentedBearerInternalReason`) in your own logs, where it belongs.
151
+ - **A failing lookup refuses.** If your callback throws, the result is
152
+ `{ ok: false, error: "unavailable" }` — a refusal you can answer with 503
153
+ instead of 401. There is no branch that grants anything when the lookup
154
+ fails.
155
+ - `deps.now` is an injectable clock, so expiry is testable without waiting.
156
+ - Types: `PresentedBearerDeps`, `StoredBearerRow` (validated at runtime by
157
+ `storedBearerRowSchema`), `ResolvedBearerIdentity`,
158
+ `ValidatePresentedBearerResult`.
159
+ - `requireAgentScopedIdentity(identity)` — narrows a resolved identity to a
160
+ surface that acts as ONE agent, and refuses when the token has no agent.
161
+ A token can be organization-wide: it names a tenant and no agent. That is a
162
+ legitimate credential, and it must never be silently widened into whichever
163
+ agent the surface happened to be about. So the agent is not a nullable field
164
+ you have to remember to check — `ResolvedBearerIdentity.agent` is an
165
+ `AgentPresence`, a union you must narrow, and the per-agent form
166
+ (`AgentScopedIdentity`, with a plain `agentId: string`) can only be obtained
167
+ from this function. Returns `RequireAgentScopedResult`; its refusal is
168
+ distinct from a bad credential, because the right answer there is 403, not
169
+ 401.
170
+ - `sha256Hex(input)` — the lower-case hex SHA-256 digest of a string. Exported
171
+ so that the code storing a token row hashes it exactly the way the resolver
172
+ will recompute it. Hash once, store the digest, discard the token.
173
+
132
174
  **Row filtering**
133
175
 
134
176
  - `passesScopeFilter(ctx, row)` — true when this row is visible to this
@@ -233,6 +275,101 @@ want to catch by type, and `BearerPayload`, the type returned by
233
275
  // -> { tenant: "org_abc", subject: "user_123", role: "admin" }
234
276
  ```
235
277
 
278
+ ## Authorize a person and bind an organisation
279
+
280
+ A connector that signs in a PERSON (a Claude or ChatGPT user) must end up with
281
+ a token naming that person and one organisation they belong to, never an
282
+ anonymous app. Clerk is the identity provider; this package holds the
283
+ decisions and takes every collaborator as a parameter.
284
+
285
+ ```ts
286
+ // GET /authorize
287
+ const out = await startAuthorize(query, clerkSessionToken, cfg, deps);
288
+ // GET /authorize/callback (the return from Clerk) and the picker's POST
289
+ const out2 = await resumeAuthorize({ state, sessionToken, orgId, approved, consentToken }, cfg, deps);
290
+ // POST /token (authorization_code grant)
291
+ const res = await exchangeAuthorizationCode(
292
+ { code, codeVerifier, redirectUri, clientId, resource },
293
+ { codeStore },
294
+ );
295
+ ```
296
+
297
+ - `startAuthorize(params, sessionToken, config, deps)` validates the request
298
+ (registered client, exact `redirect_uri`, a PKCE challenge using the SHA-256
299
+ method, an allowed RFC 8707 `resource`, supported scopes). With no valid Clerk
300
+ session it returns `{ kind: "redirect-to-sign-in", url }` and issues nothing;
301
+ the return URL carries a state blob signed with HMAC (`AuthorizeConfig.stateSecret`,
302
+ at least 32 characters) and valid for 600 seconds by default.
303
+ - `resumeAuthorize(input, config, deps)` rebuilds the request ONLY from that
304
+ signed state (a tampered or expired blob is refused), verifies the Clerk
305
+ session token (`verifyClerkSessionToken`: RS256 against the key set the
306
+ consumer supplies, issuer, expiry, optional audience and authorized parties),
307
+ lists the user's organisations through `deps.listMemberships`, and answers
308
+ with an `AuthorizeOutcome`:
309
+ `redirect-to-sign-in`, `org-picker` (an `OrgPickerModel` the consumer renders;
310
+ it lists only the user's own organisations), `redirect-to-client` (the URL
311
+ carrying the code and the client's `state`) or `refused`.
312
+ A posted `orgId` is honoured only if the verified user's own membership list
313
+ contains it.
314
+ Consent is ON by default (`requireConsent` defaults to true). What it
315
+ protects: a code is issued only after the person was SHOWN the picker and
316
+ approved it. The picker's `OrgPickerModel` carries a `consentToken`:
317
+ `HMAC(stateSecret, "consent-v1" NUL state NUL clerkUserId NUL sorted
318
+ organisation ids)`. It is bound to the exact signed state (useless for any
319
+ other request), to the verified user (useless under another session) and to
320
+ the organisation set that user was shown. `resumeAuthorize` with
321
+ `approved: true` refuses with `consent-required` unless the presented
322
+ `consentToken` verifies, compared in constant time, for that state AND the
323
+ session's user. Without this, a client could mint its own state (the sign-in
324
+ redirect hands it one), replay it with a victim's session and
325
+ `approved: true`, and receive a code the victim never saw a screen for.
326
+ The consumer renders `consentToken` as a hidden field of the picker's POST
327
+ form and posts it back as `consentToken`. The token IS the CSRF protection
328
+ for that POST, because a cross-site form cannot know it; do not add a weaker
329
+ substitute and do not accept the POST without it. Also set
330
+ `session.authorizedParties` to your own origin(s) so a Clerk session token
331
+ minted for another application (`azp`) is not accepted here.
332
+ Set `requireConsent: false` explicitly ONLY for first-party clients whose
333
+ redirect URIs you control; exactly one organisation is then auto-picked and
334
+ no token is involved.
335
+ - The code is bound to `{ clerkUserId, orgId, orgSlug, orgRole, clientId,
336
+ redirectUri, codeChallenge, resource }`, stored as a digest through the
337
+ consumer's `AuthorizationCodeStore` (`put`, and an ATOMIC `consume`), single
338
+ use, 60 seconds by default. Records are `AuthorizationCodeRecord`; a consume
339
+ answers `ConsumeCodeResult`. `AuthorizeClient`, `ClerkOrgMembership`,
340
+ `AuthorizeDeps`, `ResumeInput` and `ClerkJwk` / `ClerkJwks` /
341
+ `VerifiedClerkSession` / `VerifyClerkSessionResult` /
342
+ `ClerkSessionVerifierConfig` are the shapes the consumer supplies or reads.
343
+ - `exchangeAuthorizationCode(input, deps)` consumes the code FIRST (every
344
+ attempt burns it), then checks expiry, client, `redirect_uri`, resource and
345
+ the PKCE verifier (`pkceChallengeFromVerifier` derives a challenge). It
346
+ returns `AuthorizedTokenClaims`:
347
+ `{ sub, org_id, org_slug, org_role, aud, client_id, scope }` (`ExchangeInput`,
348
+ `ExchangeDeps`, `ExchangeResult`). No field comes from a client-registration
349
+ profile.
350
+ - `buildDiscoveryDocument(config)` builds the discovery document
351
+ (`DiscoveryConfig`, `DiscoveryDocument`, `DiscoveryResult`); the issuer must
352
+ be https with no query. It advertises only what is implemented: the code
353
+ flow, PKCE, the `authorization_code` grant and UserInfo. It omits
354
+ `id_token_signing_alg_values_supported` (OIDC Discovery lists it as required
355
+ for a provider that issues id_tokens; this package issues none, so that is a
356
+ documented deviation), refresh tokens and client-authentication methods. A
357
+ consumer that implements any of them adds those keys itself. `buildUserInfo(claims, user)` returns `sub`, and
358
+ `email` / `email_verified` when the token carries `email`
359
+ (`ClerkUserLike`, `UserInfo`, `UserInfoResult`).
360
+ - Every refusal is `{ code: "AUTHORIZE_REFUSED", reason }`
361
+ (`AuthorizeRefusal`, `AuthorizeRefusalReason`), the same shape as
362
+ `RoleRefusal`. `oauthErrorFor(refusal)` maps it to an OAuth error code and an
363
+ HTTP status (503 for a collaborator that failed, so an outage is not reported
364
+ as a bad credential). A collaborator that throws is always a refusal.
365
+
366
+ Also exported as subpaths: `/authorize`, `/token-exchange`, `/clerk-session`,
367
+ `/oidc`.
368
+
369
+ Limits, stated: `redirect_uri` matching is exact (no loopback port variation);
370
+ the package signs no access token and no id token and issues no refresh token (the consumer mints the token
371
+ from the claims); it fetches no key set and calls no Clerk API itself.
372
+
236
373
  ## What this package does not do
237
374
 
238
375
  Worth reading before you rely on it.
@@ -249,6 +386,16 @@ Worth reading before you rely on it.
249
386
  - **It does not talk to your database.** `scopeFilterList` filters rows you
250
387
  have already fetched. If fetching them was itself expensive or unsafe, filter
251
388
  earlier, in your query.
389
+ - **It does not issue, rotate or revoke tokens.** `validatePresentedBearer`
390
+ reads a row you already store and decides whether it may be believed. Minting
391
+ a token, writing its row, marking it revoked and expiring it are yours; the
392
+ package only insists that a row it is handed be believed in constant time,
393
+ and refused identically however it fails.
394
+ - **It does not verify an arbitrary signed JWT.** The presented-bearer path is
395
+ for an OPAQUE token — one whose only meaning is the row it matches. The one
396
+ signed token it verifies is a Clerk session token (`verifyClerkSessionToken`,
397
+ RS256 only). For any other signed credential, verify the signature with a
398
+ JWT library first.
252
399
 
253
400
  ## Upgrading to 0.4.0
254
401
 
@@ -0,0 +1,169 @@
1
+ import { type AuthorizeRefusal } from "./authorize-shared.js";
2
+ import { type ClerkSessionVerifierConfig } from "./clerk-session.js";
3
+ /**
4
+ * The authorize flow for a PERSON: a Clerk session, a verified organisation
5
+ * membership, and an authorization code bound to both. Framework-agnostic:
6
+ * every function takes plain values and returns a plain outcome; the consumer
7
+ * owns HTTP, rendering and storage.
8
+ *
9
+ * The code is bound to `{ clerkUserId, orgId, orgSlug, orgRole, clientId,
10
+ * redirectUri, codeChallenge, resource }`. Nothing is read from a
11
+ * client-registration profile: a client's own fields only decide whether the
12
+ * request is acceptable, never what the person may reach.
13
+ */
14
+ /** A registered OAuth client, as the consumer's own registry knows it. */
15
+ export interface AuthorizeClient {
16
+ clientId: string;
17
+ /** Exact-match list; no prefix, wildcard or partial match is ever applied. */
18
+ redirectUris: readonly string[];
19
+ clientName?: string;
20
+ revoked?: boolean;
21
+ }
22
+ /** One organisation membership, in the shape Clerk's Backend API returns. */
23
+ export interface ClerkOrgMembership {
24
+ role: string;
25
+ organization: {
26
+ id: string;
27
+ slug: string | null;
28
+ name: string;
29
+ };
30
+ }
31
+ /** A stored authorization code. Only the digest of the code is stored. */
32
+ export interface AuthorizationCodeRecord {
33
+ codeHash: string;
34
+ clerkUserId: string;
35
+ orgId: string;
36
+ orgSlug: string | null;
37
+ orgRole: string;
38
+ clientId: string;
39
+ redirectUri: string;
40
+ codeChallenge: string;
41
+ resource: string;
42
+ scope: string;
43
+ /** Epoch milliseconds. */
44
+ expiresAt: number;
45
+ }
46
+ export type ConsumeCodeResult = {
47
+ status: "ok";
48
+ record: AuthorizationCodeRecord;
49
+ } | {
50
+ status: "unknown";
51
+ } | {
52
+ status: "already-used";
53
+ };
54
+ /**
55
+ * Where codes live. `consume` MUST be atomic: of two concurrent calls for the
56
+ * same digest exactly one gets `ok`. A read-then-write is not an
57
+ * implementation of this interface.
58
+ */
59
+ export interface AuthorizationCodeStore {
60
+ put(record: AuthorizationCodeRecord): Promise<void>;
61
+ consume(codeHash: string): Promise<ConsumeCodeResult>;
62
+ }
63
+ export interface AuthorizeConfig {
64
+ /** HMAC secret for the state blob; at least 32 characters. Never sent to a client. */
65
+ stateSecret: string;
66
+ /** Clerk sign-in page. The return URL travels in `redirect_url`. */
67
+ signInUrl: string;
68
+ /** The consumer's endpoint that calls `resumeAuthorize`. */
69
+ callbackUrl: string;
70
+ /** Resource identifiers (RFC 8707) this server issues codes for. Exact match. */
71
+ allowedResources: readonly string[];
72
+ /** When set, every requested scope must be listed. */
73
+ supportedScopes?: readonly string[];
74
+ /** Scope granted when the request names none. Default `"openid"`. */
75
+ defaultScope?: string;
76
+ /** Verification of the Clerk session token. */
77
+ session: ClerkSessionVerifierConfig;
78
+ /** Seconds the signed state stays valid. Default 600. */
79
+ stateTtlSeconds?: number;
80
+ /** Seconds an authorization code stays valid. Default 60. */
81
+ codeTtlSeconds?: number;
82
+ /**
83
+ * The person must explicitly approve (the outcome is a consent model, not a
84
+ * code, until `approved: true` is posted back). DEFAULT TRUE: without it, any
85
+ * dynamically registered client could obtain a code for a signed-in victim
86
+ * who merely follows a link. Set `false` explicitly only for first-party
87
+ * clients whose redirect URIs you control; a single organisation is then
88
+ * auto-picked.
89
+ */
90
+ requireConsent?: boolean;
91
+ }
92
+ export interface AuthorizeDeps {
93
+ lookupClient(clientId: string): Promise<AuthorizeClient | null>;
94
+ /** Clerk Backend API: the user's organisation memberships. */
95
+ listMemberships(clerkUserId: string): Promise<readonly ClerkOrgMembership[]>;
96
+ codeStore: AuthorizationCodeStore;
97
+ /** Epoch milliseconds. */
98
+ now?: () => number;
99
+ /** Opaque code generator; default 32 random bytes, base64url. */
100
+ generateCode?: () => string;
101
+ }
102
+ export interface OrgPickerModel {
103
+ /** Signed state; post it back, untouched, to `resumeAuthorize`. */
104
+ state: string;
105
+ client: {
106
+ clientId: string;
107
+ clientName: string | null;
108
+ };
109
+ resource: string;
110
+ scope: string;
111
+ /** Only organisations the verified user is a member of. */
112
+ organizations: readonly {
113
+ id: string;
114
+ slug: string | null;
115
+ name: string;
116
+ role: string;
117
+ }[];
118
+ /** True when the person must tick approval even with one organisation. */
119
+ consentRequired: boolean;
120
+ /**
121
+ * Proof that THIS picker was shown to THIS verified user for THIS state.
122
+ * Render it as a hidden field and post it back as `consentToken`. Absent
123
+ * when consent is not required.
124
+ */
125
+ consentToken: string | null;
126
+ }
127
+ export type AuthorizeOutcome = {
128
+ kind: "redirect-to-sign-in";
129
+ url: string;
130
+ } | {
131
+ kind: "org-picker";
132
+ model: OrgPickerModel;
133
+ } | {
134
+ kind: "redirect-to-client";
135
+ url: string;
136
+ } | {
137
+ kind: "refused";
138
+ refusal: AuthorizeRefusal;
139
+ };
140
+ export interface ResumeInput {
141
+ /** The `authorize_state` query parameter on the return URL (or the picker's posted state). */
142
+ state: string;
143
+ /** The Clerk session token presented on return. */
144
+ sessionToken: string | undefined;
145
+ /** The organisation the person chose in the picker. */
146
+ orgId?: string;
147
+ /** The person's explicit approval, when `requireConsent` is on. */
148
+ approved?: boolean;
149
+ /** The `consentToken` of the picker the person was shown. Required with `approved: true` when consent is on. */
150
+ consentToken?: string;
151
+ }
152
+ /**
153
+ * Handles `GET /authorize`. Validates the request against the registered
154
+ * client, then:
155
+ * - no Clerk session (absent, expired or forged) -> `redirect-to-sign-in`,
156
+ * carrying a signed, short-lived state blob and issuing NO code;
157
+ * - a verified session -> continues exactly as `resumeAuthorize` does.
158
+ * A Clerk key set that cannot be reached is a refusal (`jwks-unavailable`),
159
+ * never a redirect and never a grant.
160
+ */
161
+ export declare function startAuthorize(params: Readonly<Record<string, string | undefined>>, sessionToken: string | undefined, cfg: AuthorizeConfig, deps: AuthorizeDeps): Promise<AuthorizeOutcome>;
162
+ /**
163
+ * Handles the return from Clerk (and the picker's form post). The request is
164
+ * rebuilt ONLY from the signed state: no query parameter other than the blob
165
+ * itself is read. A tampered or expired blob is refused; the client is looked
166
+ * up again so a client revoked mid-flow gets no code.
167
+ */
168
+ export declare function resumeAuthorize(input: ResumeInput, cfg: AuthorizeConfig, deps: AuthorizeDeps): Promise<AuthorizeOutcome>;
169
+ //# sourceMappingURL=authorize-flow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authorize-flow.d.ts","sourceRoot":"","sources":["../src/authorize-flow.ts"],"names":[],"mappings":"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"}