@godxjp/server-sdk 0.10.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -9
- package/dist/auth.d.ts +50 -3
- package/dist/auth.js +6 -4
- package/dist/authorization.d.ts +69 -1
- package/dist/authorization.js +110 -2
- package/dist/authorization.js.map +1 -1
- package/dist/{chunk-2N6BMNKT.js → chunk-DUFW6IQK.js} +9 -3
- package/dist/chunk-DUFW6IQK.js.map +1 -0
- package/dist/{chunk-IKMBLXCC.js → chunk-SXH6AIYT.js} +48 -2
- package/dist/chunk-SXH6AIYT.js.map +1 -0
- package/dist/hono.d.ts +6 -2
- package/dist/hono.js +27 -7
- package/dist/hono.js.map +1 -1
- package/dist/http.d.ts +8 -2
- package/dist/http.js +1 -1
- package/dist/org.js +1 -1
- package/dist/sync.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-2N6BMNKT.js.map +0 -1
- package/dist/chunk-IKMBLXCC.js.map +0 -1
package/README.md
CHANGED
|
@@ -65,26 +65,42 @@ The platform's launch URL carries `organization_context_id`; `/auth/login` forwa
|
|
|
65
65
|
back to `defaultOrganizationContextId` for local dev). A session is bound to the token's
|
|
66
66
|
`organization_id` — an org switch is a new SSO round, never a reused session.
|
|
67
67
|
|
|
68
|
+
**Mini app** (an `org_…` / `isv_…` client): leave `serviceSlug` out. The Platform routes to the
|
|
69
|
+
mini-app lane only when `service_slug` is absent, so the SDK then omits it from authorize, code
|
|
70
|
+
exchange, refresh (sent to the token endpoint as a plain `refresh_token` grant) and revoke — the
|
|
71
|
+
same rule as php-sdk (`serviceSlug === null` → omitted). `audience` defaults to `clientId`, the
|
|
72
|
+
`aud` the Platform mints mini-app access tokens for.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const sso = godxSso<AppEnv>((env) => ({
|
|
76
|
+
issuer: env.GODX_ISSUER,
|
|
77
|
+
clientId: env.GODX_CLIENT_ID, // org_… / isv_…
|
|
78
|
+
clientSecret: env.GODX_CLIENT_SECRET, // mini apps are confidential clients
|
|
79
|
+
redirectUri: `${env.APP_URL}/auth/callback`,
|
|
80
|
+
sessionSecret: env.SESSION_SECRET,
|
|
81
|
+
}));
|
|
82
|
+
```
|
|
83
|
+
|
|
68
84
|
### `GodxSsoConfig` — every field
|
|
69
85
|
|
|
70
86
|
| Field | Required | Default | Notes |
|
|
71
87
|
|---|---|---|---|
|
|
72
88
|
| `issuer` | yes | — | Platform origin; OIDC discovery is read from `{issuer}/.well-known/openid-configuration`, endpoints are never hardcoded. |
|
|
73
|
-
| `serviceSlug` | yes | — | Catalog **service** slug; sent as `service_slug` on authorize and token requests. |
|
|
74
|
-
| `clientId` | yes | — | ServiceInstance OAuth client id (`si_…`). |
|
|
89
|
+
| `serviceSlug` | services: yes; mini apps: **omit** | — | Catalog **service** slug; sent as `service_slug` on authorize and token requests. Omitted entirely when unset — that absence is what puts a mini app in its lane. |
|
|
90
|
+
| `clientId` | yes | — | ServiceInstance OAuth client id (`si_…`), or the mini app's `org_…`/`isv_…` client id. Also the expected `aud` of the ID token. |
|
|
75
91
|
| `clientSecret` | no | `undefined` | Omit for a public (secretless) instance — PKCE alone then. |
|
|
76
92
|
| `redirectUri` | yes | — | Must match the instance's `allowed_redirect_uris` exactly (scheme, host, port, path). |
|
|
77
93
|
| `sessionSecret` | yes | — | Hashed (SHA-256) into the JWE key that seals the `gx_txn` and `gx_sess` cookies (dir/A256GCM). |
|
|
78
|
-
| `scope` | no | `"openid profile email offline_access"` | Space-separated. `offline_access` gets a refresh token, so a session outlives one access token (see `POST /refresh` below); drop it and a session ends with the access token. Add `org.*` words to reach `/api/org/v1` — they are granted only as far as the signing-in user's RBAC reaches. |
|
|
94
|
+
| `scope` | no | `"openid profile email offline_access"` | Space-separated. Keep `openid`: the callback requires an ID token (nonce check). `offline_access` gets a refresh token, so a session outlives one access token (see `POST /refresh` below); drop it and a session ends with the access token. Add `org.*` words to reach `/api/org/v1` — they are granted only as far as the signing-in user's RBAC reaches. |
|
|
79
95
|
| `sessionMaxAge` | no | `2592000` (30 days) | Lifetime of a session cookie that holds a refresh token (= the platform's refresh-token lifetime). The access token inside still expires on time. |
|
|
80
|
-
| `audience` | no | `serviceSlug` | Expected token `aud` = your **ServiceInstance slug** (`boilerplate-local`), *not* the service slug. Leaving it unset only works when the two happen to be equal — they usually are not. |
|
|
96
|
+
| `audience` | no | `serviceSlug`, or `clientId` when `serviceSlug` is omitted | Expected token `aud` = your **ServiceInstance slug** (`boilerplate-local`), *not* the service slug. Leaving it unset only works when the two happen to be equal — they usually are not. |
|
|
81
97
|
| `defaultOrganizationContextId` | no | `undefined` | `console_organization_id` fallback for logins **not** started from a platform launch URL. Dev convenience only; leave unset in production multi-org setups. |
|
|
82
98
|
|
|
83
99
|
`godxSso(getConfig)` returns `{ routes, requireAuth, requireWritable, dockProxy }`:
|
|
84
100
|
|
|
85
101
|
| Member | Type | Behaviour |
|
|
86
102
|
|---|---|---|
|
|
87
|
-
| `routes` | `Hono` | `GET /login` (PKCE + state in a sealed 5-min `gx_txn` cookie, forwards `organization_context_id`, keeps `return_to` when it is a same-origin path — see `safeReturnTo`), `GET /callback` (code exchange → `verifyAccessToken` → one `userinfo` call for display name → seals `{principal, accessToken, refreshToken?}` into `gx_sess`, then redirects to `return_to` or `/`), `POST /refresh` (renews the session with the refresh token: 204 + new cookie, or 401 and the cookie cleared), `GET /logout` (best-effort revoke of the access AND refresh token, then redirect to `end_session_endpoint`). Every failure redirects to `/?sso_error=…&sso_error_description=…`. |
|
|
103
|
+
| `routes` | `Hono` | `GET /login` (PKCE + state + nonce in a sealed 5-min `gx_txn` cookie, forwards `organization_context_id`, keeps `return_to` when it is a same-origin path — see `safeReturnTo`), `GET /callback` (code exchange → `verifyAccessToken` → `verifyIdToken` against the nonce sealed in `gx_txn` at `/login`, same `sub` as the access token, else `sso_error=id_token_invalid` → one `userinfo` call for display name → seals `{principal, accessToken, refreshToken?}` into `gx_sess`, then redirects to `return_to` or `/`), `POST /refresh` (renews the session with the refresh token: 204 + new cookie, or 401 and the cookie cleared), `GET /logout` (best-effort revoke of the access AND refresh token, then redirect to `end_session_endpoint`). Every failure redirects to `/?sso_error=…&sso_error_description=…`. |
|
|
88
104
|
| `requireAuth` | middleware | Opens `gx_sess`; 401 `{"error":"unauthenticated","refreshable":bool}` when missing/expired — `refreshable: true` means `POST /refresh` can renew it without a new sign-in. Sets `c.set("principal", …)` and `c.set("accessToken", …)` (the `GodxVariables` pair). |
|
|
89
105
|
| `requireWritable` | middleware | Enforces `organization_access_mode`: non-GET/HEAD/OPTIONS from a `read_only` org answers 403. |
|
|
90
106
|
| `dockProxy` | middleware | Forwards `/api/dock/v1/*` to the platform with the session's Bearer token — the GoDX Dock `apiBase` lane: no CORS, no token in the page. Mount it behind `requireAuth` and **before** any `requireWritable` (Dock actions are the platform's to authorize). Passes `X-Request-Id` back through. |
|
|
@@ -120,8 +136,10 @@ Every export per subpath, checked against `src/*.ts`.
|
|
|
120
136
|
|
|
121
137
|
| Export | Signature (condensed) | One line |
|
|
122
138
|
|---|---|---|
|
|
123
|
-
| `Principal` | `interface { sub, organizationId, organizationContextId?, accessMode, scopes, serviceInstanceId?, clientId?, expiresAt, name?, email? }` | The verified, frozen identity. `organizationId` = claim `organization_id` (internal id); `organizationContextId` = claim `organization_context_id` (console id — the one `fetchEffectivePermissions` needs). `accessMode` defaults to `read_only` unless the claim says `full` (fail closed). |
|
|
124
|
-
| `
|
|
139
|
+
| `Principal` | `interface { sub, organizationId, organizationContextId?, accessMode, scopes, serviceInstanceId?, clientId?, expiresAt, name?, email?, issuer?, resource?, tokenId?, issuedAt?, executionId?, argumentsDigest?, serviceAuthorization? }` | The verified, frozen identity. `organizationId` = claim `organization_id` (internal id); `organizationContextId` = claim `organization_context_id` (console id — the one `fetchEffectivePermissions` needs). `accessMode` defaults to `read_only` unless the claim says `full` (fail closed). (0.11.0) `issuer`/`resource` = what the token was verified against; `executionId` = `gx_execution_id` (mutation tokens only); `argumentsDigest` = `gx_arguments_sha256`; `serviceAuthorization` = the embedded snapshot of a one-call execution token, kept only when its `execution_id` and `service` are this token's and this audience's. |
|
|
140
|
+
| `EmbeddedAuthorization` | `interface { authoritative: true, service, role, permissions, branchId, evaluatedAt, executionId }` | The `service_authorization` claim Platform embeds in a one-call execution token (godx-jp/id#2190): what `POST /api/sso/introspect` would answer, computed live at minting. (0.11.0) |
|
|
141
|
+
| `verifyAccessToken` | `(token, {issuer, serviceSlug?, audience?, http?}) => Promise<Principal>` | The one parse gate: JWKS signature, exact `iss`, `aud` (= instance slug, defaults to `serviceSlug`; a mini app passes `audience` — throws when neither is given), `exp`, then requires `organization_id`. Failures quote expected vs actual `iss`/`aud`. |
|
|
142
|
+
| `verifyIdToken` | `(token, {issuer, clientId, nonce, http?}) => Promise<claims>` | ID-token gate, php-sdk `JwtVerifier::idToken` parity: JWKS signature, exact `iss`, `aud` = `clientId`, `exp`, then the `nonce` claim compared in constant time with the one the login sent. Missing/empty/different nonce throws. (0.12.0) |
|
|
125
143
|
| `Pkce` | `interface { verifier, challenge, method: "S256" }` | A PKCE pair. |
|
|
126
144
|
| `createPkce` | `() => Promise<Pkce>` | S256 challenge via WebCrypto. |
|
|
127
145
|
| `randomToken` | `(bytes = 16) => string` | base64url random token (state, nonce). |
|
|
@@ -134,6 +152,30 @@ Every export per subpath, checked against `src/*.ts`.
|
|
|
134
152
|
|---|---|---|
|
|
135
153
|
| `EffectiveAccess` | `interface { permissions, roles, serviceAccess, contractVersion?, evaluatedAt?, authoritative }` | The effective-access read model. |
|
|
136
154
|
| `fetchEffectivePermissions` | `({issuer, accessToken, organizationId, branchId?, http?}) => Promise<EffectiveAccess>` | `GET /api/sso/me/permissions`. Pass `principal.organizationContextId` (console id) — the internal `organizationId` answers 403 CONTEXT_UNAVAILABLE; `authoritative` passed through as-is — never cache a non-authoritative answer as a positive grant. |
|
|
155
|
+
| `embeddedAccess` | `(principal) => EffectiveAccess \| null` | The permissions a one-call execution token carries (`principal.serviceAuthorization`) as `EffectiveAccess` — zero Platform calls; `null` for any other token, then fall back to `fetchEffectivePermissions`. (0.11.0) |
|
|
156
|
+
| `canonicalArgumentsDigest` | `(args) => Promise<string>` | SHA-256 hex of the arguments in the canonical form Platform locks on an approved execution (`gx_arguments_sha256`): keys sorted recursively by code point, lists kept, `{}` → `[]`, an object keyed `0..n-1` → list. Byte-identical to php-sdk's `CanonicalArguments::digest` (tests pin three PHP-computed digests). Floats are not digest-stable across languages — send decimals as strings. (0.11.0) |
|
|
157
|
+
| `ExecutionOnceOnly` | `class { constructor(store: OnceOnlyStore); claim(principal, args?) => Promise<boolean>; static key(principal) => Promise<string> }` | The service's once-only gate for a gx execution: true exactly once per (issuer, resource, executionId); false on replay, for a token without an execution, and — when `args` are given — for arguments whose digest is not `principal.argumentsDigest`. Call it BEFORE the write, on a committed store. Rows are kept `ONCE_ONLY_RETENTION_SECONDS` (7 days) past `exp`; key = `sha256("gx-execution\|iss\|resource\|executionId")`, same as php-sdk. (0.11.0) |
|
|
158
|
+
| `OnceOnlyStore` | `interface { claim(key, keepUntil) => Promise<boolean> }` | A committed, uniquely-keyed store (php-sdk's `ReplayStore` contract). |
|
|
159
|
+
| `d1OnceOnlyStore` / `memoryOnceOnlyStore` | `(db) => OnceOnlyStore` / `(now?) => OnceOnlyStore` | D1 store (`INSERT OR IGNORE`, then read the owner back — no reliance on `meta.changes`; call it OUTSIDE your own `batch` so the row commits first) and a per-isolate store for tests and dev servers only. |
|
|
160
|
+
| `ONCE_ONLY_TABLE_SQL` / `pruneOnceOnlySql` / `ONCE_ONLY_RETENTION_SECONDS` | `string` / `string` / `604800` | DDL for `godx_once_only` (copy into a migration verbatim); the prune statement for a Cron Trigger (bind current Unix seconds); the retention. |
|
|
161
|
+
|
|
162
|
+
#### Executing a gx mutation with zero Platform calls (0.11.0)
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { verifyAccessToken } from "@godxjp/server-sdk/auth";
|
|
166
|
+
import { ExecutionOnceOnly, d1OnceOnlyStore, embeddedAccess, fetchEffectivePermissions } from "@godxjp/server-sdk/authorization";
|
|
167
|
+
|
|
168
|
+
const principal = await verifyAccessToken(token, { issuer, serviceSlug, audience: resourceUri });
|
|
169
|
+
// 1. permissions: from the token when it is a one-call execution token, else one Platform call
|
|
170
|
+
const access = embeddedAccess(principal) ?? (await fetchEffectivePermissions({ issuer, accessToken: token, organizationId: principal.organizationContextId! }));
|
|
171
|
+
if (!access.permissions.includes("task.create")) return forbidden();
|
|
172
|
+
// 2. once-only + arguments check, COMMITTED before any side effect (outside your own D1 batch)
|
|
173
|
+
const once = new ExecutionOnceOnly(d1OnceOnlyStore(env.DB));
|
|
174
|
+
if (!(await once.claim(principal, args))) return conflict("execution already ran, or arguments differ from the approved ones");
|
|
175
|
+
// 3. your write, your transaction — then report the outcome AFTER the response (ctx.waitUntil)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The token lives 45 s and is bound to your resource, the approved arguments and the caller's DPoP key; verify the DPoP proof as for any gx token. Contract: godx-jp/id `docs/guide/gx-consumer-registration.md`, "One call, one token, zero Platform calls on the service (#2190)".
|
|
137
179
|
|
|
138
180
|
### `./http`
|
|
139
181
|
|
|
@@ -145,7 +187,7 @@ Every export per subpath, checked against `src/*.ts`.
|
|
|
145
187
|
| `HttpOptions` | `interface { headers?: Record<string,string> \| () => Record<string,string>; timeoutMs?: number; fetch?: typeof fetch }` | Accepted as `http` by `GodxSsoClient`, `createServiceApi`, `createOrgApi`, `createSnapshotClient`, `verifyAccessToken`, the Hono config, and per call by `fetchEffectivePermissions`. Send your `X-Request-Id` (a function mints one per call); `timeoutMs` aborts and throws `GodxTimeoutError`; SDK-set headers always win. (0.10.0) |
|
|
146
188
|
| `GodxTimeoutError` | `class extends Error { url, timeoutMs }` | The request did not answer within `timeoutMs`. |
|
|
147
189
|
| `SsoClientConfig` / `TokenResponse` / `UserinfoResponse` | interfaces | Client config and wire shapes. |
|
|
148
|
-
| `GodxSsoClient` | `class (config)` — `authorizeUrl(params)`, `exchangeCode({code, codeVerifier})`, `refresh(refreshToken)`, `revoke(token)`, `userinfo(accessToken)` | The OAuth/OIDC client. `authorizeUrl` sends `service_slug`, PKCE S256 and optional `organization_context_id`; `revoke` is a no-op when discovery lists no revocation endpoint. |
|
|
190
|
+
| `GodxSsoClient` | `class (config)` — `authorizeUrl(params)`, `exchangeCode({code, codeVerifier})`, `refresh(refreshToken)`, `revoke(token)`, `userinfo(accessToken)` | The OAuth/OIDC client. `authorizeUrl` sends `service_slug` (omitted when `serviceSlug` is unset — a mini app; `refresh` then uses the token endpoint), `nonce`, PKCE S256 and optional `organization_context_id`; `revoke` is a no-op when discovery lists no revocation endpoint. |
|
|
149
191
|
| `createServiceApi` | `({issuer, serviceSlug, serviceSecret}) => { users(q), branches(q), brands(q) }` | Server-to-server reads with `X-Service-Slug`/`X-Service-Secret`. Rate-limited by the platform (429) — cache and back off; no auto-retry here. |
|
|
150
192
|
|
|
151
193
|
### `./hono`
|
|
@@ -576,7 +618,15 @@ CREATE INDEX IF NOT EXISTS outbox_unpublished_idx ON outbox (published_at, seque
|
|
|
576
618
|
npx vitest run # from packages/server-sdk
|
|
577
619
|
```
|
|
578
620
|
|
|
579
|
-
|
|
621
|
+
74 tests, doubling as executable behaviour docs:
|
|
622
|
+
|
|
623
|
+
- `auth.test.ts` (2): a signed one-call execution token through the real parse gate — issuer,
|
|
624
|
+
resource, jti, iat, execution id, argument digest, embedded snapshot kept; a snapshot for another
|
|
625
|
+
execution / another service / non-authoritative dropped; a read token carries no execution.
|
|
626
|
+
- `authorization.test.ts` (9): canonical digest parity with php-sdk (three PHP-computed digests:
|
|
627
|
+
nested sort, `{}` → `[]`, object keyed 0..n-1 → list, code-point key order), once-only key parity,
|
|
628
|
+
claim-once + 7-day retention, refusals (read token, tampered arguments, digest-less token), D1 and
|
|
629
|
+
memory stores, `embeddedAccess` mapping.
|
|
580
630
|
|
|
581
631
|
- `sync.test.ts` (23): envelope validation (specversion/subject/source pinning), webhook
|
|
582
632
|
sign/verify (tamper, replay, rotation, raw-secret fallback), processor outcomes (duplicate,
|
package/dist/auth.d.ts
CHANGED
|
@@ -22,6 +22,39 @@ interface Principal {
|
|
|
22
22
|
/** From userinfo, when the adapter fetched it at login. */
|
|
23
23
|
readonly name?: string;
|
|
24
24
|
readonly email?: string;
|
|
25
|
+
/** The exact `iss` the token was verified against (discovery issuer). Absent on sessions sealed before 0.11.0. */
|
|
26
|
+
readonly issuer?: string;
|
|
27
|
+
/** The audience the token was verified against — your resource URI / instance slug. Absent on sessions sealed before 0.11.0. */
|
|
28
|
+
readonly resource?: string;
|
|
29
|
+
/** Claim `jti`. */
|
|
30
|
+
readonly tokenId?: string;
|
|
31
|
+
/** Unix seconds, from `iat`. */
|
|
32
|
+
readonly issuedAt?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Claim `gx_execution_id` — set only on a gx MUTATION token (a one-call execution token or a
|
|
35
|
+
* checkpointed one); never on a read token. The service's once-only gate keys on it (godx-jp/id#2190).
|
|
36
|
+
*/
|
|
37
|
+
readonly executionId?: string;
|
|
38
|
+
/** Claim `gx_arguments_sha256` — SHA-256 hex of the approved arguments in canonical form (one-call execution token). */
|
|
39
|
+
readonly argumentsDigest?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Claim `service_authorization` of a one-call execution token: byte-for-byte what
|
|
42
|
+
* `POST /api/sso/introspect` would answer for this token, computed live at minting (F = 0). Only
|
|
43
|
+
* present when its `execution_id` names THIS token's execution and its `service` is THIS audience —
|
|
44
|
+
* otherwise it is dropped at parse and the caller falls back to `fetchEffectivePermissions`.
|
|
45
|
+
*/
|
|
46
|
+
readonly serviceAuthorization?: EmbeddedAuthorization;
|
|
47
|
+
}
|
|
48
|
+
/** The `service_authorization` snapshot Platform embeds in a one-call execution token (godx-jp/id#2190). */
|
|
49
|
+
interface EmbeddedAuthorization {
|
|
50
|
+
readonly authoritative: true;
|
|
51
|
+
readonly service: string;
|
|
52
|
+
readonly role: string | null;
|
|
53
|
+
readonly permissions: readonly string[];
|
|
54
|
+
readonly branchId: string | null;
|
|
55
|
+
/** ISO-8601, the moment Platform computed the snapshot — a few ms before `iat`. */
|
|
56
|
+
readonly evaluatedAt: string;
|
|
57
|
+
readonly executionId: string;
|
|
25
58
|
}
|
|
26
59
|
/**
|
|
27
60
|
* The one parse gate. Validates signature (JWKS), iss (discovery issuer,
|
|
@@ -29,12 +62,26 @@ interface Principal {
|
|
|
29
62
|
*/
|
|
30
63
|
declare function verifyAccessToken(token: string, opts: {
|
|
31
64
|
issuer: string;
|
|
32
|
-
|
|
33
|
-
|
|
65
|
+
/** Omitted by a mini app, whose tokens have no service slug — `audience` is then required. */
|
|
66
|
+
serviceSlug?: string;
|
|
67
|
+
/** The token audience = your ServiceInstance slug (a mini app: its client_id). Defaults to serviceSlug. */
|
|
34
68
|
audience?: string;
|
|
35
69
|
/** Headers, deadline and fetch for discovery and JWKS (first call per issuer decides the JWKS options). */
|
|
36
70
|
http?: HttpOptions;
|
|
37
71
|
}): Promise<Principal>;
|
|
72
|
+
/**
|
|
73
|
+
* Verifies an ID token the way php-sdk's JwtVerifier::idToken does: JWKS signature, exact `iss`,
|
|
74
|
+
* `aud` = the client_id, `exp`, then the `nonce` claim against the one this login sent —
|
|
75
|
+
* compared in constant time. Any mismatch or absence throws (fail closed).
|
|
76
|
+
*/
|
|
77
|
+
declare function verifyIdToken(token: string, opts: {
|
|
78
|
+
issuer: string;
|
|
79
|
+
clientId: string;
|
|
80
|
+
nonce: string;
|
|
81
|
+
http?: HttpOptions;
|
|
82
|
+
}): Promise<Record<string, unknown> & {
|
|
83
|
+
sub: string;
|
|
84
|
+
}>;
|
|
38
85
|
interface Pkce {
|
|
39
86
|
verifier: string;
|
|
40
87
|
challenge: string;
|
|
@@ -47,4 +94,4 @@ declare function sealData(payload: Record<string, unknown>, secret: string, ttlS
|
|
|
47
94
|
/** Returns null for anything invalid or expired — never throws on bad input. */
|
|
48
95
|
declare function openData<T = Record<string, unknown>>(token: string, secret: string): Promise<T | null>;
|
|
49
96
|
|
|
50
|
-
export { type Pkce, type Principal, createPkce, openData, randomToken, sealData, verifyAccessToken };
|
|
97
|
+
export { type EmbeddedAuthorization, type Pkce, type Principal, createPkce, openData, randomToken, sealData, verifyAccessToken, verifyIdToken };
|
package/dist/auth.js
CHANGED
|
@@ -3,14 +3,16 @@ import {
|
|
|
3
3
|
openData,
|
|
4
4
|
randomToken,
|
|
5
5
|
sealData,
|
|
6
|
-
verifyAccessToken
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
verifyAccessToken,
|
|
7
|
+
verifyIdToken
|
|
8
|
+
} from "./chunk-SXH6AIYT.js";
|
|
9
|
+
import "./chunk-DUFW6IQK.js";
|
|
9
10
|
export {
|
|
10
11
|
createPkce,
|
|
11
12
|
openData,
|
|
12
13
|
randomToken,
|
|
13
14
|
sealData,
|
|
14
|
-
verifyAccessToken
|
|
15
|
+
verifyAccessToken,
|
|
16
|
+
verifyIdToken
|
|
15
17
|
};
|
|
16
18
|
//# sourceMappingURL=auth.js.map
|
package/dist/authorization.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Principal } from './auth.js';
|
|
1
2
|
import { HttpOptions } from './http.js';
|
|
2
3
|
import 'jose';
|
|
3
4
|
|
|
@@ -27,5 +28,72 @@ declare function fetchEffectivePermissions(params: {
|
|
|
27
28
|
/** `headers` (send your X-Request-Id), `timeoutMs` (a hung Platform must not hang you), `fetch`. */
|
|
28
29
|
http?: HttpOptions;
|
|
29
30
|
}): Promise<EffectiveAccess>;
|
|
31
|
+
/**
|
|
32
|
+
* The permissions a one-call execution token carries with it (godx-jp/id#2190): `EffectiveAccess`
|
|
33
|
+
* built from `principal.serviceAuthorization`, so a mutation needs ZERO Platform calls on the service.
|
|
34
|
+
* `null` for any other token — then call `fetchEffectivePermissions` as before. The snapshot was
|
|
35
|
+
* computed live at minting and the token lives 45 s, so its age is bounded by `exp`; nothing here is
|
|
36
|
+
* cached beyond the token itself.
|
|
37
|
+
*/
|
|
38
|
+
declare function embeddedAccess(principal: Principal): EffectiveAccess | null;
|
|
39
|
+
/**
|
|
40
|
+
* The argument digest Platform locks on an approved execution and puts in the one-call execution
|
|
41
|
+
* token as `gx_arguments_sha256` — computed HERE the same way as php-sdk's CanonicalArguments and
|
|
42
|
+
* Platform's ActionIntentService::digest(): object keys sorted recursively (byte order, as PHP's
|
|
43
|
+
* `SORT_STRING`), lists kept in order, JSON without escaped slashes or unicode, SHA-256 hex.
|
|
44
|
+
*
|
|
45
|
+
* PHP decodes JSON into arrays, and two of its habits are mirrored on purpose: an EMPTY object
|
|
46
|
+
* digests as `[]`, and an object whose sorted keys read exactly "0","1",…,"n-1" digests as a list.
|
|
47
|
+
* Floats are the one thing no JS code can mirror (`1.0` keeps its zero fraction in PHP, JS has no
|
|
48
|
+
* such number): send decimals as strings through gx, as the platform guide says.
|
|
49
|
+
*/
|
|
50
|
+
declare function canonicalArgumentsDigest(args: Record<string, unknown> | unknown[]): Promise<string>;
|
|
51
|
+
/**
|
|
52
|
+
* A committed, uniquely-keyed store: `claim` returns true exactly once per key until `keepUntil`
|
|
53
|
+
* (Unix seconds) has passed. Same contract as php-sdk's ReplayStore.
|
|
54
|
+
*/
|
|
55
|
+
interface OnceOnlyStore {
|
|
56
|
+
claim(key: string, keepUntil: number): Promise<boolean>;
|
|
57
|
+
}
|
|
58
|
+
/** Structural subset of D1Database, so this module has no CF type dependency (see ./producer). */
|
|
59
|
+
interface D1Like {
|
|
60
|
+
prepare(sql: string): {
|
|
61
|
+
bind(...values: unknown[]): {
|
|
62
|
+
first<T = unknown>(): Promise<T | null>;
|
|
63
|
+
run(): Promise<unknown>;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** DDL for the once-only table — copy into a migration verbatim. Prune with `pruneOnceOnlySql`. */
|
|
68
|
+
declare const ONCE_ONLY_TABLE_SQL = "CREATE TABLE IF NOT EXISTS godx_once_only (\n claim_key TEXT PRIMARY KEY,\n owner TEXT NOT NULL,\n keep_until INTEGER NOT NULL\n);\nCREATE INDEX IF NOT EXISTS godx_once_only_keep_until ON godx_once_only (keep_until);";
|
|
69
|
+
/** Run from a Cron Trigger (bind the current Unix seconds); rows are kept 7 days past the token's exp on purpose. */
|
|
70
|
+
declare const pruneOnceOnlySql = "DELETE FROM godx_once_only WHERE keep_until < ?";
|
|
71
|
+
/**
|
|
72
|
+
* D1-backed store. Insert-or-ignore then read the owner back, so the verdict does not depend on
|
|
73
|
+
* `meta.changes` being reported. The row must COMMIT before any side effect, which on D1 means:
|
|
74
|
+
* call `claim` outside your own `batch`, not inside it.
|
|
75
|
+
*/
|
|
76
|
+
declare function d1OnceOnlyStore(db: D1Like): OnceOnlyStore;
|
|
77
|
+
/** Per-isolate store for tests and single-process dev servers. NOT once-only across isolates or restarts. */
|
|
78
|
+
declare function memoryOnceOnlyStore(now?: () => number): OnceOnlyStore;
|
|
79
|
+
/** How long a consumed execution stays on record after its token expired (php-sdk: the same 7 days). */
|
|
80
|
+
declare const ONCE_ONLY_RETENTION_SECONDS: number;
|
|
81
|
+
/**
|
|
82
|
+
* The service's own once-only gate for a gx execution (godx-jp/id#2190). With the one-call token the
|
|
83
|
+
* service no longer asks Platform to checkpoint before each write, so "this execution runs at most
|
|
84
|
+
* once" is the service's promise: `claim` BEFORE applying the write; a retry, a replayed request or a
|
|
85
|
+
* second worker gets false. The row outlives the token by ONCE_ONLY_RETENTION_SECONDS — a row pruned
|
|
86
|
+
* at exp plus verifier leeway is a replay window — and, when the tool arguments are handed over, they
|
|
87
|
+
* must digest to the token's `gx_arguments_sha256` (the comparison the synchronous checkpoint used to
|
|
88
|
+
* make). Mirrors php-sdk's ExecutionOnceOnly, key included.
|
|
89
|
+
*/
|
|
90
|
+
declare class ExecutionOnceOnly {
|
|
91
|
+
private readonly store;
|
|
92
|
+
constructor(store: OnceOnlyStore);
|
|
93
|
+
/** True exactly once per (issuer, resource, execution id); false on replay, for a token without an execution, and for arguments that are not the approved ones. */
|
|
94
|
+
claim(principal: Principal, args?: Record<string, unknown> | unknown[]): Promise<boolean>;
|
|
95
|
+
/** sha256("gx-execution|" + issuer + "|" + resource + "|" + executionId) — byte-identical to php-sdk's key. */
|
|
96
|
+
static key(principal: Principal): Promise<string>;
|
|
97
|
+
}
|
|
30
98
|
|
|
31
|
-
export { type EffectiveAccess, fetchEffectivePermissions };
|
|
99
|
+
export { type EffectiveAccess, ExecutionOnceOnly, ONCE_ONLY_RETENTION_SECONDS, ONCE_ONLY_TABLE_SQL, type OnceOnlyStore, canonicalArgumentsDigest, d1OnceOnlyStore, embeddedAccess, fetchEffectivePermissions, memoryOnceOnlyStore, pruneOnceOnlySql };
|
package/dist/authorization.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
GodxHttpError,
|
|
3
3
|
platformFetch
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-DUFW6IQK.js";
|
|
5
5
|
|
|
6
6
|
// src/authorization.ts
|
|
7
7
|
async function fetchEffectivePermissions(params) {
|
|
@@ -26,7 +26,115 @@ async function fetchEffectivePermissions(params) {
|
|
|
26
26
|
authoritative: body.authoritative === true
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
function embeddedAccess(principal) {
|
|
30
|
+
const snapshot = principal.serviceAuthorization;
|
|
31
|
+
if (!snapshot) return null;
|
|
32
|
+
return {
|
|
33
|
+
permissions: [...snapshot.permissions],
|
|
34
|
+
roles: snapshot.role ? [snapshot.role] : [],
|
|
35
|
+
serviceAccess: {},
|
|
36
|
+
evaluatedAt: snapshot.evaluatedAt,
|
|
37
|
+
authoritative: true
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function canonicalArgumentsDigest(args) {
|
|
41
|
+
const json = JSON.stringify(canonical(args));
|
|
42
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(json));
|
|
43
|
+
return hex(new Uint8Array(digest));
|
|
44
|
+
}
|
|
45
|
+
function canonical(value) {
|
|
46
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
47
|
+
if (typeof value !== "object" || value === null) return value;
|
|
48
|
+
const keys = Object.keys(value).sort(compareCodePoints);
|
|
49
|
+
const record = value;
|
|
50
|
+
if (keys.every((key, index) => key === String(index))) {
|
|
51
|
+
return keys.map((key) => canonical(record[key]));
|
|
52
|
+
}
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const key of keys) out[key] = canonical(record[key]);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function compareCodePoints(a, b) {
|
|
58
|
+
const left = Array.from(a, (c) => c.codePointAt(0) ?? 0);
|
|
59
|
+
const right = Array.from(b, (c) => c.codePointAt(0) ?? 0);
|
|
60
|
+
for (let i = 0; i < Math.min(left.length, right.length); i++) {
|
|
61
|
+
if (left[i] !== right[i]) return (left[i] ?? 0) - (right[i] ?? 0);
|
|
62
|
+
}
|
|
63
|
+
return left.length - right.length;
|
|
64
|
+
}
|
|
65
|
+
function hex(bytes) {
|
|
66
|
+
let out = "";
|
|
67
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function digestsEqual(a, b) {
|
|
71
|
+
if (a.length !== b.length) return false;
|
|
72
|
+
let diff = 0;
|
|
73
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
74
|
+
return diff === 0;
|
|
75
|
+
}
|
|
76
|
+
var ONCE_ONLY_TABLE_SQL = `CREATE TABLE IF NOT EXISTS godx_once_only (
|
|
77
|
+
claim_key TEXT PRIMARY KEY,
|
|
78
|
+
owner TEXT NOT NULL,
|
|
79
|
+
keep_until INTEGER NOT NULL
|
|
80
|
+
);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS godx_once_only_keep_until ON godx_once_only (keep_until);`;
|
|
82
|
+
var pruneOnceOnlySql = "DELETE FROM godx_once_only WHERE keep_until < ?";
|
|
83
|
+
function d1OnceOnlyStore(db) {
|
|
84
|
+
return {
|
|
85
|
+
async claim(key, keepUntil) {
|
|
86
|
+
const owner = crypto.randomUUID();
|
|
87
|
+
await db.prepare("INSERT OR IGNORE INTO godx_once_only (claim_key, owner, keep_until) VALUES (?, ?, ?)").bind(key, owner, keepUntil).run();
|
|
88
|
+
const row = await db.prepare("SELECT owner FROM godx_once_only WHERE claim_key = ?").bind(key).first();
|
|
89
|
+
return row?.owner === owner;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function memoryOnceOnlyStore(now = () => Math.floor(Date.now() / 1e3)) {
|
|
94
|
+
const rows = /* @__PURE__ */ new Map();
|
|
95
|
+
return {
|
|
96
|
+
async claim(key, keepUntil) {
|
|
97
|
+
const current = now();
|
|
98
|
+
for (const [k, until] of rows) if (until < current) rows.delete(k);
|
|
99
|
+
if (rows.has(key)) return false;
|
|
100
|
+
rows.set(key, keepUntil);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
var ONCE_ONLY_RETENTION_SECONDS = 7 * 86400;
|
|
106
|
+
var ExecutionOnceOnly = class _ExecutionOnceOnly {
|
|
107
|
+
constructor(store) {
|
|
108
|
+
this.store = store;
|
|
109
|
+
}
|
|
110
|
+
store;
|
|
111
|
+
/** True exactly once per (issuer, resource, execution id); false on replay, for a token without an execution, and for arguments that are not the approved ones. */
|
|
112
|
+
async claim(principal, args) {
|
|
113
|
+
if (!principal.executionId) return false;
|
|
114
|
+
if (args !== void 0) {
|
|
115
|
+
if (!principal.argumentsDigest) return false;
|
|
116
|
+
if (!digestsEqual(principal.argumentsDigest, await canonicalArgumentsDigest(args))) return false;
|
|
117
|
+
}
|
|
118
|
+
return this.store.claim(await _ExecutionOnceOnly.key(principal), principal.expiresAt + ONCE_ONLY_RETENTION_SECONDS);
|
|
119
|
+
}
|
|
120
|
+
/** sha256("gx-execution|" + issuer + "|" + resource + "|" + executionId) — byte-identical to php-sdk's key. */
|
|
121
|
+
static async key(principal) {
|
|
122
|
+
if (!principal.issuer || !principal.resource || !principal.executionId) {
|
|
123
|
+
throw new Error("ExecutionOnceOnly needs a Principal from verifyAccessToken \u2265 0.11.0 carrying issuer, resource and executionId");
|
|
124
|
+
}
|
|
125
|
+
const material = `gx-execution|${principal.issuer}|${principal.resource}|${principal.executionId}`;
|
|
126
|
+
return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material))));
|
|
127
|
+
}
|
|
128
|
+
};
|
|
29
129
|
export {
|
|
30
|
-
|
|
130
|
+
ExecutionOnceOnly,
|
|
131
|
+
ONCE_ONLY_RETENTION_SECONDS,
|
|
132
|
+
ONCE_ONLY_TABLE_SQL,
|
|
133
|
+
canonicalArgumentsDigest,
|
|
134
|
+
d1OnceOnlyStore,
|
|
135
|
+
embeddedAccess,
|
|
136
|
+
fetchEffectivePermissions,
|
|
137
|
+
memoryOnceOnlyStore,
|
|
138
|
+
pruneOnceOnlySql
|
|
31
139
|
};
|
|
32
140
|
//# sourceMappingURL=authorization.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/authorization.ts"],"sourcesContent":["/**\n * ./authorization — effective-access read model. Mirrors php-sdk's\n * Authorization module: results carry the platform's authoritative flag,\n * nothing is cached as a positive grant here.\n */\nimport { GodxHttpError, type HttpOptions, platformFetch } from \"./http\";\n\nexport interface EffectiveAccess {\n permissions: string[];\n roles: string[];\n serviceAccess: Record<string, unknown>;\n contractVersion?: string;\n evaluatedAt?: string;\n authoritative: boolean;\n}\n\nexport async function fetchEffectivePermissions(params: {\n issuer: string;\n accessToken: string;\n /**\n * The CONSOLE organization id — `principal.organizationContextId`, not\n * `principal.organizationId` (the internal id answers 403 CONTEXT_UNAVAILABLE).\n */\n organizationId: string;\n branchId?: string;\n /** `headers` (send your X-Request-Id), `timeoutMs` (a hung Platform must not hang you), `fetch`. */\n http?: HttpOptions;\n}): Promise<EffectiveAccess> {\n const url = new URL(\"/api/sso/me/permissions\", params.issuer);\n url.search = new URLSearchParams({\n organization_id: params.organizationId,\n ...(params.branchId ? { branch_id: params.branchId } : {}),\n }).toString();\n\n const res = await platformFetch(url, {\n headers: { Authorization: `Bearer ${params.accessToken}` },\n }, params.http);\n if (!res.ok) {\n throw new GodxHttpError(res.status, await res.text(), \"effective permissions failed\");\n }\n const body = (await res.json()) as {\n permissions?: string[];\n roles?: string[];\n service_access?: Record<string, unknown>;\n contract_version?: string;\n evaluated_at?: string;\n authoritative?: boolean;\n };\n return {\n permissions: body.permissions ?? [],\n roles: body.roles ?? [],\n serviceAccess: body.service_access ?? {},\n contractVersion: body.contract_version,\n evaluatedAt: body.evaluated_at,\n authoritative: body.authoritative === true,\n };\n}\n"],"mappings":";;;;;;AAgBA,eAAsB,0BAA0B,QAWnB;AAC3B,QAAM,MAAM,IAAI,IAAI,2BAA2B,OAAO,MAAM;AAC5D,MAAI,SAAS,IAAI,gBAAgB;AAAA,IAC/B,iBAAiB,OAAO;AAAA,IACxB,GAAI,OAAO,WAAW,EAAE,WAAW,OAAO,SAAS,IAAI,CAAC;AAAA,EAC1D,CAAC,EAAE,SAAS;AAEZ,QAAM,MAAM,MAAM,cAAc,KAAK;AAAA,IACnC,SAAS,EAAE,eAAe,UAAU,OAAO,WAAW,GAAG;AAAA,EAC3D,GAAG,OAAO,IAAI;AACd,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,IAAI,cAAc,IAAI,QAAQ,MAAM,IAAI,KAAK,GAAG,8BAA8B;AAAA,EACtF;AACA,QAAM,OAAQ,MAAM,IAAI,KAAK;AAQ7B,SAAO;AAAA,IACL,aAAa,KAAK,eAAe,CAAC;AAAA,IAClC,OAAO,KAAK,SAAS,CAAC;AAAA,IACtB,eAAe,KAAK,kBAAkB,CAAC;AAAA,IACvC,iBAAiB,KAAK;AAAA,IACtB,aAAa,KAAK;AAAA,IAClB,eAAe,KAAK,kBAAkB;AAAA,EACxC;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/authorization.ts"],"sourcesContent":["/**\n * ./authorization — effective-access read model. Mirrors php-sdk's\n * Authorization module: results carry the platform's authoritative flag,\n * nothing is cached as a positive grant here.\n */\nimport type { Principal } from \"./auth\";\nimport { GodxHttpError, type HttpOptions, platformFetch } from \"./http\";\n\nexport interface EffectiveAccess {\n permissions: string[];\n roles: string[];\n serviceAccess: Record<string, unknown>;\n contractVersion?: string;\n evaluatedAt?: string;\n authoritative: boolean;\n}\n\nexport async function fetchEffectivePermissions(params: {\n issuer: string;\n accessToken: string;\n /**\n * The CONSOLE organization id — `principal.organizationContextId`, not\n * `principal.organizationId` (the internal id answers 403 CONTEXT_UNAVAILABLE).\n */\n organizationId: string;\n branchId?: string;\n /** `headers` (send your X-Request-Id), `timeoutMs` (a hung Platform must not hang you), `fetch`. */\n http?: HttpOptions;\n}): Promise<EffectiveAccess> {\n const url = new URL(\"/api/sso/me/permissions\", params.issuer);\n url.search = new URLSearchParams({\n organization_id: params.organizationId,\n ...(params.branchId ? { branch_id: params.branchId } : {}),\n }).toString();\n\n const res = await platformFetch(url, {\n headers: { Authorization: `Bearer ${params.accessToken}` },\n }, params.http);\n if (!res.ok) {\n throw new GodxHttpError(res.status, await res.text(), \"effective permissions failed\");\n }\n const body = (await res.json()) as {\n permissions?: string[];\n roles?: string[];\n service_access?: Record<string, unknown>;\n contract_version?: string;\n evaluated_at?: string;\n authoritative?: boolean;\n };\n return {\n permissions: body.permissions ?? [],\n roles: body.roles ?? [],\n serviceAccess: body.service_access ?? {},\n contractVersion: body.contract_version,\n evaluatedAt: body.evaluated_at,\n authoritative: body.authoritative === true,\n };\n}\n\n/**\n * The permissions a one-call execution token carries with it (godx-jp/id#2190): `EffectiveAccess`\n * built from `principal.serviceAuthorization`, so a mutation needs ZERO Platform calls on the service.\n * `null` for any other token — then call `fetchEffectivePermissions` as before. The snapshot was\n * computed live at minting and the token lives 45 s, so its age is bounded by `exp`; nothing here is\n * cached beyond the token itself.\n */\nexport function embeddedAccess(principal: Principal): EffectiveAccess | null {\n const snapshot = principal.serviceAuthorization;\n if (!snapshot) return null;\n return {\n permissions: [...snapshot.permissions],\n roles: snapshot.role ? [snapshot.role] : [],\n serviceAccess: {},\n evaluatedAt: snapshot.evaluatedAt,\n authoritative: true,\n };\n}\n\n/**\n * The argument digest Platform locks on an approved execution and puts in the one-call execution\n * token as `gx_arguments_sha256` — computed HERE the same way as php-sdk's CanonicalArguments and\n * Platform's ActionIntentService::digest(): object keys sorted recursively (byte order, as PHP's\n * `SORT_STRING`), lists kept in order, JSON without escaped slashes or unicode, SHA-256 hex.\n *\n * PHP decodes JSON into arrays, and two of its habits are mirrored on purpose: an EMPTY object\n * digests as `[]`, and an object whose sorted keys read exactly \"0\",\"1\",…,\"n-1\" digests as a list.\n * Floats are the one thing no JS code can mirror (`1.0` keeps its zero fraction in PHP, JS has no\n * such number): send decimals as strings through gx, as the platform guide says.\n */\nexport async function canonicalArgumentsDigest(args: Record<string, unknown> | unknown[]): Promise<string> {\n const json = JSON.stringify(canonical(args));\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(json));\n return hex(new Uint8Array(digest));\n}\n\nfunction canonical(value: unknown): unknown {\n if (Array.isArray(value)) return value.map(canonical);\n if (typeof value !== \"object\" || value === null) return value;\n const keys = Object.keys(value as Record<string, unknown>).sort(compareCodePoints);\n const record = value as Record<string, unknown>;\n if (keys.every((key, index) => key === String(index))) {\n return keys.map((key) => canonical(record[key]));\n }\n const out: Record<string, unknown> = {};\n for (const key of keys) out[key] = canonical(record[key]);\n return out;\n}\n\n/** UTF-8 byte order == code point order; JS's default sort compares UTF-16 units, which differs above U+D7FF. */\nfunction compareCodePoints(a: string, b: string): number {\n const left = Array.from(a, (c) => c.codePointAt(0) ?? 0);\n const right = Array.from(b, (c) => c.codePointAt(0) ?? 0);\n for (let i = 0; i < Math.min(left.length, right.length); i++) {\n if (left[i] !== right[i]) return (left[i] ?? 0) - (right[i] ?? 0);\n }\n return left.length - right.length;\n}\n\nfunction hex(bytes: Uint8Array): string {\n let out = \"\";\n for (const b of bytes) out += b.toString(16).padStart(2, \"0\");\n return out;\n}\n\n/** Constant-time equality for two hex digests. */\nfunction digestsEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n return diff === 0;\n}\n\n/**\n * A committed, uniquely-keyed store: `claim` returns true exactly once per key until `keepUntil`\n * (Unix seconds) has passed. Same contract as php-sdk's ReplayStore.\n */\nexport interface OnceOnlyStore {\n claim(key: string, keepUntil: number): Promise<boolean>;\n}\n\n/** Structural subset of D1Database, so this module has no CF type dependency (see ./producer). */\ninterface D1Like {\n prepare(sql: string): {\n bind(...values: unknown[]): {\n first<T = unknown>(): Promise<T | null>;\n run(): Promise<unknown>;\n };\n };\n}\n\n/** DDL for the once-only table — copy into a migration verbatim. Prune with `pruneOnceOnlySql`. */\nexport const ONCE_ONLY_TABLE_SQL = `CREATE TABLE IF NOT EXISTS godx_once_only (\n claim_key TEXT PRIMARY KEY,\n owner TEXT NOT NULL,\n keep_until INTEGER NOT NULL\n);\nCREATE INDEX IF NOT EXISTS godx_once_only_keep_until ON godx_once_only (keep_until);`;\n\n/** Run from a Cron Trigger (bind the current Unix seconds); rows are kept 7 days past the token's exp on purpose. */\nexport const pruneOnceOnlySql = \"DELETE FROM godx_once_only WHERE keep_until < ?\";\n\n/**\n * D1-backed store. Insert-or-ignore then read the owner back, so the verdict does not depend on\n * `meta.changes` being reported. The row must COMMIT before any side effect, which on D1 means:\n * call `claim` outside your own `batch`, not inside it.\n */\nexport function d1OnceOnlyStore(db: D1Like): OnceOnlyStore {\n return {\n async claim(key, keepUntil) {\n const owner = crypto.randomUUID();\n await db\n .prepare(\"INSERT OR IGNORE INTO godx_once_only (claim_key, owner, keep_until) VALUES (?, ?, ?)\")\n .bind(key, owner, keepUntil)\n .run();\n const row = await db.prepare(\"SELECT owner FROM godx_once_only WHERE claim_key = ?\").bind(key).first<{ owner: string }>();\n return row?.owner === owner;\n },\n };\n}\n\n/** Per-isolate store for tests and single-process dev servers. NOT once-only across isolates or restarts. */\nexport function memoryOnceOnlyStore(now: () => number = () => Math.floor(Date.now() / 1000)): OnceOnlyStore {\n const rows = new Map<string, number>();\n return {\n async claim(key, keepUntil) {\n const current = now();\n for (const [k, until] of rows) if (until < current) rows.delete(k);\n if (rows.has(key)) return false;\n rows.set(key, keepUntil);\n return true;\n },\n };\n}\n\n/** How long a consumed execution stays on record after its token expired (php-sdk: the same 7 days). */\nexport const ONCE_ONLY_RETENTION_SECONDS = 7 * 86400;\n\n/**\n * The service's own once-only gate for a gx execution (godx-jp/id#2190). With the one-call token the\n * service no longer asks Platform to checkpoint before each write, so \"this execution runs at most\n * once\" is the service's promise: `claim` BEFORE applying the write; a retry, a replayed request or a\n * second worker gets false. The row outlives the token by ONCE_ONLY_RETENTION_SECONDS — a row pruned\n * at exp plus verifier leeway is a replay window — and, when the tool arguments are handed over, they\n * must digest to the token's `gx_arguments_sha256` (the comparison the synchronous checkpoint used to\n * make). Mirrors php-sdk's ExecutionOnceOnly, key included.\n */\nexport class ExecutionOnceOnly {\n constructor(private readonly store: OnceOnlyStore) {}\n\n /** True exactly once per (issuer, resource, execution id); false on replay, for a token without an execution, and for arguments that are not the approved ones. */\n async claim(principal: Principal, args?: Record<string, unknown> | unknown[]): Promise<boolean> {\n if (!principal.executionId) return false;\n if (args !== undefined) {\n if (!principal.argumentsDigest) return false;\n if (!digestsEqual(principal.argumentsDigest, await canonicalArgumentsDigest(args))) return false;\n }\n return this.store.claim(await ExecutionOnceOnly.key(principal), principal.expiresAt + ONCE_ONLY_RETENTION_SECONDS);\n }\n\n /** sha256(\"gx-execution|\" + issuer + \"|\" + resource + \"|\" + executionId) — byte-identical to php-sdk's key. */\n static async key(principal: Principal): Promise<string> {\n if (!principal.issuer || !principal.resource || !principal.executionId) {\n throw new Error(\"ExecutionOnceOnly needs a Principal from verifyAccessToken ≥ 0.11.0 carrying issuer, resource and executionId\");\n }\n const material = `gx-execution|${principal.issuer}|${principal.resource}|${principal.executionId}`;\n return hex(new Uint8Array(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(material))));\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -82,7 +82,7 @@ var GodxSsoClient = class {
|
|
|
82
82
|
const { metadata } = await loadIssuer(this.config.issuer, this.config.http);
|
|
83
83
|
const url = new URL(metadata.authorization_endpoint);
|
|
84
84
|
url.search = new URLSearchParams({
|
|
85
|
-
|
|
85
|
+
...this.serviceSlugField(),
|
|
86
86
|
client_id: this.config.clientId,
|
|
87
87
|
redirect_uri: this.config.redirectUri,
|
|
88
88
|
response_type: "code",
|
|
@@ -105,6 +105,9 @@ var GodxSsoClient = class {
|
|
|
105
105
|
}
|
|
106
106
|
async refresh(refreshToken) {
|
|
107
107
|
const { metadata } = await loadIssuer(this.config.issuer, this.config.http);
|
|
108
|
+
if (this.config.serviceSlug === void 0) {
|
|
109
|
+
return this.postForm(metadata.token_endpoint, { grant_type: "refresh_token", refresh_token: refreshToken });
|
|
110
|
+
}
|
|
108
111
|
return this.postForm(new URL("/api/sso/refresh", metadata.issuer).toString(), {
|
|
109
112
|
grant_type: "refresh_token",
|
|
110
113
|
refresh_token: refreshToken,
|
|
@@ -134,7 +137,7 @@ var GodxSsoClient = class {
|
|
|
134
137
|
}
|
|
135
138
|
async postForm(url, fields) {
|
|
136
139
|
const body = new URLSearchParams({
|
|
137
|
-
|
|
140
|
+
...this.serviceSlugField(),
|
|
138
141
|
client_id: this.config.clientId,
|
|
139
142
|
...this.config.clientSecret ? { client_secret: this.config.clientSecret } : {},
|
|
140
143
|
...fields
|
|
@@ -147,6 +150,9 @@ var GodxSsoClient = class {
|
|
|
147
150
|
if (!res.ok) throw new GodxHttpError(res.status, await res.text(), "token request failed");
|
|
148
151
|
return await res.json();
|
|
149
152
|
}
|
|
153
|
+
serviceSlugField() {
|
|
154
|
+
return this.config.serviceSlug === void 0 ? {} : { service_slug: this.config.serviceSlug };
|
|
155
|
+
}
|
|
150
156
|
};
|
|
151
157
|
function createServiceApi(config) {
|
|
152
158
|
const get = async (path, query) => {
|
|
@@ -180,4 +186,4 @@ export {
|
|
|
180
186
|
GodxSsoClient,
|
|
181
187
|
createServiceApi
|
|
182
188
|
};
|
|
183
|
-
//# sourceMappingURL=chunk-
|
|
189
|
+
//# sourceMappingURL=chunk-DUFW6IQK.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/http.ts"],"sourcesContent":["/**\n * ./http — endpoint contract from OIDC discovery, the SSO client, and the\n * server-to-server service API. Runtime-agnostic: fetch + WebCrypto only.\n * Mirrors php-sdk's Http module boundary.\n */\nimport { createRemoteJWKSet, customFetch } from \"jose\";\n\n/**\n * Per-call knobs for every outbound request this SDK makes (godx-jp/server-sdk#17, from the\n * pages.godx.jp incident of 2026-10-04 where a Platform call hung 25–180 s with no request id\n * to find it by). Pass them on the client/config (`http`) or per call where a function takes\n * them; defaults are exactly the old behaviour.\n */\nexport interface HttpOptions {\n /**\n * Extra headers on every request — `X-Request-Id` is the one that makes a hang findable in\n * Platform's log. A function is called per request (mint a fresh id each time); a header the\n * SDK itself sets (Authorization, Content-Type, X-Service-*) always wins.\n */\n headers?: Record<string, string> | (() => Record<string, string>);\n /** Abort a request after this many milliseconds and throw {@link GodxTimeoutError}. Unset = the runtime's default (often none). */\n timeoutMs?: number;\n /** The fetch to use (tests, custom dispatchers). Default `globalThis.fetch`, read at call time. */\n fetch?: typeof fetch;\n}\n\n/** The request did not answer within `timeoutMs`; `url` and `timeoutMs` say which and how long. */\nexport class GodxTimeoutError extends Error {\n readonly url: string;\n readonly timeoutMs: number;\n constructor(url: string, timeoutMs: number) {\n super(`GoDX platform request to ${url} did not answer within ${timeoutMs} ms`);\n this.name = \"GodxTimeoutError\";\n this.url = url;\n this.timeoutMs = timeoutMs;\n }\n}\n\n/**\n * `fetch` with {@link HttpOptions} applied: merged headers, a deadline, an injectable fetch.\n * Every outbound call in this package goes through here, so one option reaches all of them.\n */\nexport async function platformFetch(\n input: URL | string,\n init: RequestInit = {},\n options: HttpOptions = {},\n): Promise<Response> {\n const headers = new Headers(init.headers);\n const extra = typeof options.headers === \"function\" ? options.headers() : options.headers;\n for (const [name, value] of Object.entries(extra ?? {})) {\n if (!headers.has(name)) headers.set(name, value);\n }\n const doFetch = options.fetch ?? globalThis.fetch;\n if (options.timeoutMs === undefined || options.timeoutMs <= 0) {\n return doFetch(input, { ...init, headers });\n }\n const controller = new AbortController();\n let timedOut = false;\n const timer = setTimeout(() => {\n timedOut = true;\n controller.abort();\n }, options.timeoutMs);\n init.signal?.addEventListener(\"abort\", () => controller.abort(), { once: true });\n try {\n return await doFetch(input, { ...init, headers, signal: controller.signal });\n } catch (error) {\n if (timedOut) throw new GodxTimeoutError(String(input), options.timeoutMs);\n throw error;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface IssuerMetadata {\n issuer: string;\n authorization_endpoint: string;\n token_endpoint: string;\n userinfo_endpoint: string;\n jwks_uri: string;\n end_session_endpoint?: string;\n revocation_endpoint?: string;\n}\n\nexport interface Issuer {\n metadata: IssuerMetadata;\n jwks: ReturnType<typeof createRemoteJWKSet>;\n}\n\nexport class GodxHttpError extends Error {\n readonly status: number;\n readonly body: string;\n\n constructor(status: number, body: string, message?: string) {\n super(message ?? `GoDX platform request failed with ${status}`);\n this.name = \"GodxHttpError\";\n this.status = status;\n this.body = body;\n }\n}\n\n// Per-isolate cache; the platform docs require reading discovery, not hardcoding paths.\nconst issuerCache = new Map<string, Promise<Issuer>>();\n\n/**\n * Discovery + JWKS, cached per isolate and issuer. `options` applies to the discovery request and\n * to every JWKS refresh of the cached set (`timeoutMs` → jose `timeoutDuration`; static headers\n * only — a header FUNCTION cannot run inside jose's refresh, so it is evaluated once here). The\n * first call for an issuer decides the JWKS options; a failed load evicts itself.\n */\nexport function loadIssuer(issuerUrl: string, options: HttpOptions = {}): Promise<Issuer> {\n let cached = issuerCache.get(issuerUrl);\n if (!cached) {\n cached = (async () => {\n const res = await platformFetch(new URL(\"/.well-known/openid-configuration\", issuerUrl), {}, options);\n if (!res.ok) {\n issuerCache.delete(issuerUrl);\n throw new GodxHttpError(res.status, await res.text(), \"OIDC discovery failed\");\n }\n const metadata = (await res.json()) as IssuerMetadata;\n const jwksHeaders = typeof options.headers === \"function\" ? options.headers() : options.headers;\n const jwks = createRemoteJWKSet(new URL(metadata.jwks_uri), {\n ...(options.timeoutMs ? { timeoutDuration: options.timeoutMs } : {}),\n ...(jwksHeaders ? { headers: jwksHeaders } : {}),\n // jose keys its fetch override by its OWN exported symbol (a plain Symbol(), not\n // Symbol.for): 0.10.0 passed Symbol.for(\"jose.fetch\") and the JWKS fetch silently ignored\n // `http.fetch` — caught by pages' lead (godx-jp/server-sdk#19).\n ...(options.fetch ? { [customFetch]: options.fetch } : {}),\n });\n return { metadata, jwks };\n })();\n issuerCache.set(issuerUrl, cached);\n cached.catch(() => issuerCache.delete(issuerUrl));\n }\n return cached;\n}\n\nexport interface SsoClientConfig {\n issuer: string;\n /**\n * Catalog service slug, sent as `service_slug`. OMIT it for a mini app (`org_…`/`isv_…`\n * client): the Platform routes to the mini-app lane only when `service_slug` is absent —\n * the same rule as php-sdk's BrowserProfile/BrowserServer (`serviceSlug === null` → omitted).\n */\n serviceSlug?: string;\n clientId: string;\n /** Omit for a public (secretless) instance — PKCE alone then. */\n clientSecret?: string;\n redirectUri: string;\n /** Token audience = the ServiceInstance slug (defaults to serviceSlug; a mini app: its client_id). */\n audience?: string;\n /** Headers, deadline and fetch for every request this client makes. */\n http?: HttpOptions;\n}\n\nexport interface TokenResponse {\n access_token: string;\n token_type: string;\n expires_in: number;\n id_token?: string;\n refresh_token?: string;\n scope?: string;\n}\n\nexport interface UserinfoResponse {\n sub: string;\n email?: string;\n email_verified?: boolean;\n name?: string;\n locale?: string;\n timezone?: string;\n}\n\nexport class GodxSsoClient {\n readonly config: SsoClientConfig;\n\n constructor(config: SsoClientConfig) {\n this.config = config;\n }\n\n async authorizeUrl(params: {\n state: string;\n nonce: string;\n codeChallenge: string;\n scope?: string;\n /** console_organization_id — required by the launch gates; usually arrives on the launch URL. */\n organizationContextId?: string;\n }): Promise<string> {\n const { metadata } = await loadIssuer(this.config.issuer, this.config.http);\n const url = new URL(metadata.authorization_endpoint);\n url.search = new URLSearchParams({\n ...this.serviceSlugField(),\n client_id: this.config.clientId,\n redirect_uri: this.config.redirectUri,\n response_type: \"code\",\n scope: params.scope ?? \"openid profile email\",\n state: params.state,\n nonce: params.nonce,\n code_challenge: params.codeChallenge,\n code_challenge_method: \"S256\",\n ...(params.organizationContextId\n ? { organization_context_id: params.organizationContextId }\n : {}),\n }).toString();\n return url.toString();\n }\n\n async exchangeCode(params: { code: string; codeVerifier: string }): Promise<TokenResponse> {\n return this.tokenRequest(\"token_endpoint\", {\n grant_type: \"authorization_code\",\n redirect_uri: this.config.redirectUri,\n code: params.code,\n code_verifier: params.codeVerifier,\n });\n }\n\n async refresh(refreshToken: string): Promise<TokenResponse> {\n const { metadata } = await loadIssuer(this.config.issuer, this.config.http);\n // A mini app refreshes as an ordinary RFC 6749 §6 client at the token endpoint, like\n // php-sdk does: /api/sso/refresh is the ServiceInstance lane and requires service_slug.\n if (this.config.serviceSlug === undefined) {\n return this.postForm(metadata.token_endpoint, { grant_type: \"refresh_token\", refresh_token: refreshToken });\n }\n return this.postForm(new URL(\"/api/sso/refresh\", metadata.issuer).toString(), {\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n // The platform files a refresh token under the INSTANCE slug (the token's audience,\n // e.g. \"godx-logger-prod\"), not the catalog service slug: sending the service slug\n // finds no row and answers invalid_grant (measured against id.godx.jp).\n service_slug: this.config.audience ?? this.config.serviceSlug,\n });\n }\n\n /** Best-effort revocation; failures are the caller's decision to ignore. */\n async revoke(token: string): Promise<void> {\n const { metadata } = await loadIssuer(this.config.issuer, this.config.http);\n if (!metadata.revocation_endpoint) return;\n await this.postForm(metadata.revocation_endpoint, { token });\n }\n\n async userinfo(accessToken: string): Promise<UserinfoResponse> {\n const { metadata } = await loadIssuer(this.config.issuer, this.config.http);\n const res = await platformFetch(metadata.userinfo_endpoint, {\n headers: { Authorization: `Bearer ${accessToken}` },\n }, this.config.http);\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), \"userinfo failed\");\n return (await res.json()) as UserinfoResponse;\n }\n\n private async tokenRequest(\n endpoint: \"token_endpoint\",\n fields: Record<string, string>,\n ): Promise<TokenResponse> {\n const { metadata } = await loadIssuer(this.config.issuer, this.config.http);\n return this.postForm(metadata[endpoint], fields);\n }\n\n private async postForm(url: string, fields: Record<string, string>): Promise<TokenResponse> {\n const body = new URLSearchParams({\n ...this.serviceSlugField(),\n client_id: this.config.clientId,\n ...(this.config.clientSecret ? { client_secret: this.config.clientSecret } : {}),\n ...fields,\n });\n const res = await platformFetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body,\n }, this.config.http);\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), \"token request failed\");\n return (await res.json()) as TokenResponse;\n }\n\n private serviceSlugField(): { service_slug?: string } {\n return this.config.serviceSlug === undefined ? {} : { service_slug: this.config.serviceSlug };\n }\n}\n\n/**\n * Server-to-server reads with service credentials (X-Service-Slug/X-Service-Secret).\n * Rate-limited by the platform (429) — cache results and back off; no auto-retry here.\n */\nexport function createServiceApi(config: {\n issuer: string;\n serviceSlug: string;\n serviceSecret: string;\n http?: HttpOptions;\n}) {\n const get = async <T>(path: string, query: Record<string, string>): Promise<T> => {\n const url = new URL(path, config.issuer);\n url.search = new URLSearchParams(query).toString();\n const res = await platformFetch(url, {\n headers: {\n \"X-Service-Slug\": config.serviceSlug,\n \"X-Service-Secret\": config.serviceSecret,\n },\n }, config.http);\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), `service API ${path} failed`);\n return (await res.json()) as T;\n };\n\n return {\n users: (q: { organizationSlug: string; perPage?: number; page?: number }) =>\n get<unknown>(\"/api/sso/service/users\", {\n organization_slug: q.organizationSlug,\n ...(q.perPage ? { per_page: String(q.perPage) } : {}),\n ...(q.page ? { page: String(q.page) } : {}),\n }),\n branches: (q: { organizationSlug: string }) =>\n get<unknown>(\"/api/sso/service/branches\", { organization_slug: q.organizationSlug }),\n brands: (q: { organizationSlug: string }) =>\n get<unknown>(\"/api/sso/service/brands\", { organization_slug: q.organizationSlug }),\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
loadIssuer
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-DUFW6IQK.js";
|
|
4
4
|
|
|
5
5
|
// src/auth.ts
|
|
6
6
|
import { EncryptJWT, jwtDecrypt, jwtVerify } from "jose";
|
|
7
7
|
async function verifyAccessToken(token, opts) {
|
|
8
8
|
const expectedAudience = opts.audience ?? opts.serviceSlug;
|
|
9
|
+
if (!expectedAudience) throw new Error("verifyAccessToken needs audience (or serviceSlug)");
|
|
9
10
|
const { metadata, jwks } = await loadIssuer(opts.issuer, opts.http);
|
|
10
11
|
let payload;
|
|
11
12
|
try {
|
|
@@ -27,8 +28,16 @@ async function verifyAccessToken(token, opts) {
|
|
|
27
28
|
if (typeof payload.sub !== "string" || typeof payload.exp !== "number") {
|
|
28
29
|
throw new Error("access token is missing sub/exp");
|
|
29
30
|
}
|
|
31
|
+
const executionId = nonEmptyString(payload["gx_execution_id"]);
|
|
30
32
|
return Object.freeze({
|
|
31
33
|
sub: payload.sub,
|
|
34
|
+
issuer: metadata.issuer,
|
|
35
|
+
resource: expectedAudience,
|
|
36
|
+
tokenId: nonEmptyString(payload.jti),
|
|
37
|
+
issuedAt: typeof payload.iat === "number" ? payload.iat : void 0,
|
|
38
|
+
executionId,
|
|
39
|
+
argumentsDigest: nonEmptyString(payload["gx_arguments_sha256"]),
|
|
40
|
+
serviceAuthorization: embeddedAuthorization(payload["service_authorization"], executionId, expectedAudience),
|
|
32
41
|
organizationId,
|
|
33
42
|
organizationContextId: typeof payload["organization_context_id"] === "string" && payload["organization_context_id"] !== "" ? payload["organization_context_id"] : void 0,
|
|
34
43
|
accessMode: payload["organization_access_mode"] === "full" ? "full" : "read_only",
|
|
@@ -38,6 +47,22 @@ async function verifyAccessToken(token, opts) {
|
|
|
38
47
|
expiresAt: payload.exp
|
|
39
48
|
});
|
|
40
49
|
}
|
|
50
|
+
async function verifyIdToken(token, opts) {
|
|
51
|
+
const { metadata, jwks } = await loadIssuer(opts.issuer, opts.http);
|
|
52
|
+
const { payload } = await jwtVerify(token, jwks, { issuer: metadata.issuer, audience: opts.clientId });
|
|
53
|
+
if (opts.nonce === "" || typeof payload["nonce"] !== "string" || !constantTimeEqual(opts.nonce, payload["nonce"])) {
|
|
54
|
+
throw new Error("ID token nonce mismatch");
|
|
55
|
+
}
|
|
56
|
+
if (typeof payload.sub !== "string" || payload.sub === "") throw new Error("ID token has no sub");
|
|
57
|
+
return payload;
|
|
58
|
+
}
|
|
59
|
+
function constantTimeEqual(a, b) {
|
|
60
|
+
const x = new TextEncoder().encode(a);
|
|
61
|
+
const y = new TextEncoder().encode(b);
|
|
62
|
+
let diff = x.length ^ y.length;
|
|
63
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
|
|
64
|
+
return diff === 0;
|
|
65
|
+
}
|
|
41
66
|
async function createPkce() {
|
|
42
67
|
const verifier = randomToken(32);
|
|
43
68
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
@@ -48,6 +73,26 @@ function randomToken(bytes = 16) {
|
|
|
48
73
|
crypto.getRandomValues(buf);
|
|
49
74
|
return base64url(buf);
|
|
50
75
|
}
|
|
76
|
+
function nonEmptyString(value) {
|
|
77
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
78
|
+
}
|
|
79
|
+
function embeddedAuthorization(raw, executionId, resource) {
|
|
80
|
+
if (!executionId || typeof raw !== "object" || raw === null) return void 0;
|
|
81
|
+
const snapshot = raw;
|
|
82
|
+
const permissions = snapshot["permissions"];
|
|
83
|
+
if (snapshot["authoritative"] !== true || snapshot["service"] !== resource || snapshot["execution_id"] !== executionId || !Array.isArray(permissions) || !permissions.every((p) => typeof p === "string") || typeof snapshot["evaluated_at"] !== "string") {
|
|
84
|
+
return void 0;
|
|
85
|
+
}
|
|
86
|
+
return Object.freeze({
|
|
87
|
+
authoritative: true,
|
|
88
|
+
service: resource,
|
|
89
|
+
role: typeof snapshot["role"] === "string" ? snapshot["role"] : null,
|
|
90
|
+
permissions: Object.freeze([...permissions]),
|
|
91
|
+
branchId: typeof snapshot["branch_id"] === "string" ? snapshot["branch_id"] : null,
|
|
92
|
+
evaluatedAt: snapshot["evaluated_at"],
|
|
93
|
+
executionId
|
|
94
|
+
});
|
|
95
|
+
}
|
|
51
96
|
function decodeUnverified(token) {
|
|
52
97
|
try {
|
|
53
98
|
const part = token.split(".")[1] ?? "";
|
|
@@ -80,9 +125,10 @@ async function openData(token, secret) {
|
|
|
80
125
|
|
|
81
126
|
export {
|
|
82
127
|
verifyAccessToken,
|
|
128
|
+
verifyIdToken,
|
|
83
129
|
createPkce,
|
|
84
130
|
randomToken,
|
|
85
131
|
sealData,
|
|
86
132
|
openData
|
|
87
133
|
};
|
|
88
|
-
//# sourceMappingURL=chunk-
|
|
134
|
+
//# sourceMappingURL=chunk-SXH6AIYT.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/auth.ts"],"sourcesContent":["/**\n * ./auth — verified immutable Principal, PKCE, and sealed session values.\n * Mirrors php-sdk's Auth module: the Principal only comes out of one parse\n * gate (verifyAccessToken) and is frozen after that.\n */\nimport { EncryptJWT, jwtDecrypt, jwtVerify } from \"jose\";\nimport { type HttpOptions, loadIssuer } from \"./http\";\n\nexport interface Principal {\n readonly sub: string;\n /** Claim organization_id — the organization's INTERNAL id. */\n readonly organizationId: string;\n /**\n * Claim organization_context_id — the CONSOLE organization id, when the token carries one.\n * This is the id the platform's organization-scoped APIs look organizations up by\n * (`/api/sso/me/permissions`, `fetchEffectivePermissions`); passing `organizationId` there\n * answers 403 CONTEXT_UNAVAILABLE (godx-jp/server-sdk#6).\n */\n readonly organizationContextId?: string;\n /** Defaults to read_only unless the claim says full — fail closed. */\n readonly accessMode: \"full\" | \"read_only\";\n readonly scopes: readonly string[];\n readonly serviceInstanceId?: string;\n readonly clientId?: string;\n /** Unix seconds, from the access token's exp. */\n readonly expiresAt: number;\n /** From userinfo, when the adapter fetched it at login. */\n readonly name?: string;\n readonly email?: string;\n /** The exact `iss` the token was verified against (discovery issuer). Absent on sessions sealed before 0.11.0. */\n readonly issuer?: string;\n /** The audience the token was verified against — your resource URI / instance slug. Absent on sessions sealed before 0.11.0. */\n readonly resource?: string;\n /** Claim `jti`. */\n readonly tokenId?: string;\n /** Unix seconds, from `iat`. */\n readonly issuedAt?: number;\n /**\n * Claim `gx_execution_id` — set only on a gx MUTATION token (a one-call execution token or a\n * checkpointed one); never on a read token. The service's once-only gate keys on it (godx-jp/id#2190).\n */\n readonly executionId?: string;\n /** Claim `gx_arguments_sha256` — SHA-256 hex of the approved arguments in canonical form (one-call execution token). */\n readonly argumentsDigest?: string;\n /**\n * Claim `service_authorization` of a one-call execution token: byte-for-byte what\n * `POST /api/sso/introspect` would answer for this token, computed live at minting (F = 0). Only\n * present when its `execution_id` names THIS token's execution and its `service` is THIS audience —\n * otherwise it is dropped at parse and the caller falls back to `fetchEffectivePermissions`.\n */\n readonly serviceAuthorization?: EmbeddedAuthorization;\n}\n\n/** The `service_authorization` snapshot Platform embeds in a one-call execution token (godx-jp/id#2190). */\nexport interface EmbeddedAuthorization {\n readonly authoritative: true;\n readonly service: string;\n readonly role: string | null;\n readonly permissions: readonly string[];\n readonly branchId: string | null;\n /** ISO-8601, the moment Platform computed the snapshot — a few ms before `iat`. */\n readonly evaluatedAt: string;\n readonly executionId: string;\n}\n\n/**\n * The one parse gate. Validates signature (JWKS), iss (discovery issuer,\n * exact), aud (= service slug), exp — then requires organization_id.\n */\nexport async function verifyAccessToken(\n token: string,\n opts: {\n issuer: string;\n /** Omitted by a mini app, whose tokens have no service slug — `audience` is then required. */\n serviceSlug?: string;\n /** The token audience = your ServiceInstance slug (a mini app: its client_id). Defaults to serviceSlug. */\n audience?: string;\n /** Headers, deadline and fetch for discovery and JWKS (first call per issuer decides the JWKS options). */\n http?: HttpOptions;\n },\n): Promise<Principal> {\n const expectedAudience = opts.audience ?? opts.serviceSlug;\n // Without an expected audience jose would accept a token minted for ANY client.\n if (!expectedAudience) throw new Error(\"verifyAccessToken needs audience (or serviceSlug)\");\n const { metadata, jwks } = await loadIssuer(opts.issuer, opts.http);\n let payload;\n try {\n ({ payload } = await jwtVerify(token, jwks, {\n issuer: metadata.issuer,\n audience: expectedAudience,\n }));\n } catch (e) {\n // Say what was expected vs what the token actually carries — a bare\n // \"unexpected claim\" costs whoever reads it a debugging session.\n const actual = decodeUnverified(token);\n throw new Error(\n `${e instanceof Error ? e.message : String(e)} — expected iss=\"${metadata.issuer}\" aud=\"${expectedAudience}\", ` +\n `token carries iss=\"${String(actual?.iss ?? \"?\")}\" aud=${JSON.stringify(actual?.aud ?? \"?\")}`,\n { cause: e },\n );\n }\n\n const organizationId = payload[\"organization_id\"];\n if (typeof organizationId !== \"string\" || organizationId === \"\") {\n throw new Error(\"access token has no organization_id claim\");\n }\n if (typeof payload.sub !== \"string\" || typeof payload.exp !== \"number\") {\n throw new Error(\"access token is missing sub/exp\");\n }\n\n const executionId = nonEmptyString(payload[\"gx_execution_id\"]);\n return Object.freeze({\n sub: payload.sub,\n issuer: metadata.issuer,\n resource: expectedAudience,\n tokenId: nonEmptyString(payload.jti),\n issuedAt: typeof payload.iat === \"number\" ? payload.iat : undefined,\n executionId,\n argumentsDigest: nonEmptyString(payload[\"gx_arguments_sha256\"]),\n serviceAuthorization: embeddedAuthorization(payload[\"service_authorization\"], executionId, expectedAudience),\n organizationId,\n organizationContextId:\n typeof payload[\"organization_context_id\"] === \"string\" && payload[\"organization_context_id\"] !== \"\"\n ? payload[\"organization_context_id\"]\n : undefined,\n accessMode: payload[\"organization_access_mode\"] === \"full\" ? \"full\" : \"read_only\",\n scopes: Object.freeze(String(payload[\"scope\"] ?? \"\").split(\" \").filter(Boolean)),\n serviceInstanceId: typeof payload[\"service_instance_id\"] === \"string\" ? payload[\"service_instance_id\"] : undefined,\n clientId: typeof payload[\"client_id\"] === \"string\" ? payload[\"client_id\"] : undefined,\n expiresAt: payload.exp,\n } satisfies Principal);\n}\n/**\n * Verifies an ID token the way php-sdk's JwtVerifier::idToken does: JWKS signature, exact `iss`,\n * `aud` = the client_id, `exp`, then the `nonce` claim against the one this login sent —\n * compared in constant time. Any mismatch or absence throws (fail closed).\n */\nexport async function verifyIdToken(\n token: string,\n opts: { issuer: string; clientId: string; nonce: string; http?: HttpOptions },\n): Promise<Record<string, unknown> & { sub: string }> {\n const { metadata, jwks } = await loadIssuer(opts.issuer, opts.http);\n const { payload } = await jwtVerify(token, jwks, { issuer: metadata.issuer, audience: opts.clientId });\n if (opts.nonce === \"\" || typeof payload[\"nonce\"] !== \"string\" || !constantTimeEqual(opts.nonce, payload[\"nonce\"])) {\n throw new Error(\"ID token nonce mismatch\");\n }\n if (typeof payload.sub !== \"string\" || payload.sub === \"\") throw new Error(\"ID token has no sub\");\n return payload as Record<string, unknown> & { sub: string };\n}\n\n/** Length-folded XOR over UTF-8 bytes — no node:crypto, so it runs on Workers too. */\nfunction constantTimeEqual(a: string, b: string): boolean {\n const x = new TextEncoder().encode(a);\n const y = new TextEncoder().encode(b);\n let diff = x.length ^ y.length;\n for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);\n return diff === 0;\n}\n\nexport interface Pkce {\n verifier: string;\n challenge: string;\n method: \"S256\";\n}\n\nexport async function createPkce(): Promise<Pkce> {\n const verifier = randomToken(32);\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(verifier));\n return { verifier, challenge: base64url(new Uint8Array(digest)), method: \"S256\" };\n}\n\nexport function randomToken(bytes = 16): string {\n const buf = new Uint8Array(bytes);\n crypto.getRandomValues(buf);\n return base64url(buf);\n}\n\nfunction nonEmptyString(value: unknown): string | undefined {\n return typeof value === \"string\" && value !== \"\" ? value : undefined;\n}\n\n/**\n * Accept the embedded snapshot only when it is provably THIS token's and THIS service's — the same\n * four checks php-sdk's TokenEmbeddedPermissionSource makes. Anything else → undefined (fall back to\n * introspection); never a partial object.\n */\nfunction embeddedAuthorization(\n raw: unknown,\n executionId: string | undefined,\n resource: string,\n): EmbeddedAuthorization | undefined {\n if (!executionId || typeof raw !== \"object\" || raw === null) return undefined;\n const snapshot = raw as Record<string, unknown>;\n const permissions = snapshot[\"permissions\"];\n if (\n snapshot[\"authoritative\"] !== true ||\n snapshot[\"service\"] !== resource ||\n snapshot[\"execution_id\"] !== executionId ||\n !Array.isArray(permissions) ||\n !permissions.every((p): p is string => typeof p === \"string\") ||\n typeof snapshot[\"evaluated_at\"] !== \"string\"\n ) {\n return undefined;\n }\n return Object.freeze({\n authoritative: true,\n service: resource,\n role: typeof snapshot[\"role\"] === \"string\" ? snapshot[\"role\"] : null,\n permissions: Object.freeze([...permissions]),\n branchId: typeof snapshot[\"branch_id\"] === \"string\" ? snapshot[\"branch_id\"] : null,\n evaluatedAt: snapshot[\"evaluated_at\"],\n executionId,\n });\n}\n\n/** Diagnostics only — an UNVERIFIED read of the payload for error messages. */\nfunction decodeUnverified(token: string): Record<string, unknown> | null {\n try {\n const part = token.split(\".\")[1] ?? \"\";\n const pad = part.replace(/-/g, \"+\").replace(/_/g, \"/\");\n return JSON.parse(atob(pad)) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let bin = \"\";\n for (const b of bytes) bin += String.fromCharCode(b);\n return btoa(bin).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function sessionKey(secret: string): Promise<Uint8Array> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(secret));\n return new Uint8Array(digest);\n}\n\n/** Encrypt + sign an arbitrary payload into a compact cookie value (JWE dir/A256GCM). */\nexport async function sealData(\n payload: Record<string, unknown>,\n secret: string,\n ttlSeconds: number,\n): Promise<string> {\n return new EncryptJWT(payload)\n .setProtectedHeader({ alg: \"dir\", enc: \"A256GCM\" })\n .setIssuedAt()\n .setExpirationTime(Math.floor(Date.now() / 1000) + ttlSeconds)\n .encrypt(await sessionKey(secret));\n}\n\n/** Returns null for anything invalid or expired — never throws on bad input. */\nexport async function openData<T = Record<string, unknown>>(\n token: string,\n secret: string,\n): Promise<T | null> {\n try {\n const { payload } = await jwtDecrypt(token, await sessionKey(secret));\n return payload as T;\n } catch {\n return null;\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/hono.d.ts
CHANGED
|
@@ -6,7 +6,8 @@ import 'jose';
|
|
|
6
6
|
|
|
7
7
|
interface GodxSsoConfig {
|
|
8
8
|
issuer: string;
|
|
9
|
-
serviceSlug
|
|
9
|
+
/** Catalog service slug. OMIT it for a mini app — see SsoClientConfig.serviceSlug. */
|
|
10
|
+
serviceSlug?: string;
|
|
10
11
|
clientId: string;
|
|
11
12
|
clientSecret?: string;
|
|
12
13
|
redirectUri: string;
|
|
@@ -21,7 +22,10 @@ interface GodxSsoConfig {
|
|
|
21
22
|
* platform's refresh-token lifetime). The access token inside is still short-lived.
|
|
22
23
|
*/
|
|
23
24
|
sessionMaxAge?: number;
|
|
24
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Token audience = your ServiceInstance slug (defaults to serviceSlug). A mini app (no
|
|
27
|
+
* serviceSlug) defaults to its clientId — the Platform mints mini-app access tokens for it.
|
|
28
|
+
*/
|
|
25
29
|
audience?: string;
|
|
26
30
|
/**
|
|
27
31
|
* console_organization_id to fall back on when the login was not started
|
package/dist/hono.js
CHANGED
|
@@ -3,13 +3,14 @@ import {
|
|
|
3
3
|
openData,
|
|
4
4
|
randomToken,
|
|
5
5
|
sealData,
|
|
6
|
-
verifyAccessToken
|
|
7
|
-
|
|
6
|
+
verifyAccessToken,
|
|
7
|
+
verifyIdToken
|
|
8
|
+
} from "./chunk-SXH6AIYT.js";
|
|
8
9
|
import {
|
|
9
10
|
GodxSsoClient,
|
|
10
11
|
loadIssuer,
|
|
11
12
|
platformFetch
|
|
12
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-DUFW6IQK.js";
|
|
13
14
|
|
|
14
15
|
// src/hono.ts
|
|
15
16
|
import { Hono } from "hono";
|
|
@@ -27,8 +28,11 @@ function safeReturnTo(raw) {
|
|
|
27
28
|
}
|
|
28
29
|
var SESSION_COOKIE = "gx_sess";
|
|
29
30
|
function godxSso(getConfig) {
|
|
30
|
-
const client = (c) => new GodxSsoClient(
|
|
31
|
-
const cfg = (c) =>
|
|
31
|
+
const client = (c) => new GodxSsoClient(cfg(c));
|
|
32
|
+
const cfg = (c) => {
|
|
33
|
+
const config = getConfig(c.env);
|
|
34
|
+
return config.serviceSlug === void 0 && config.audience === void 0 ? { ...config, audience: config.clientId } : config;
|
|
35
|
+
};
|
|
32
36
|
const secure = (c) => c.req.url.startsWith("https:");
|
|
33
37
|
const writeSession = async (c, data) => {
|
|
34
38
|
const config = cfg(c);
|
|
@@ -49,7 +53,7 @@ function godxSso(getConfig) {
|
|
|
49
53
|
const nonce = randomToken();
|
|
50
54
|
const organizationContextId = c.req.query("organization_context_id") ?? config.defaultOrganizationContextId;
|
|
51
55
|
const returnTo = safeReturnTo(c.req.query("return_to"));
|
|
52
|
-
setCookie(c, TXN_COOKIE, await sealData({ v: pkce.verifier, s: state, r: returnTo }, config.sessionSecret, 300), {
|
|
56
|
+
setCookie(c, TXN_COOKIE, await sealData({ v: pkce.verifier, s: state, n: nonce, r: returnTo }, config.sessionSecret, 300), {
|
|
53
57
|
httpOnly: true,
|
|
54
58
|
secure: secure(c),
|
|
55
59
|
sameSite: "Lax",
|
|
@@ -84,7 +88,7 @@ function godxSso(getConfig) {
|
|
|
84
88
|
const txnCookie = getCookie(c, TXN_COOKIE);
|
|
85
89
|
deleteCookie(c, TXN_COOKIE, { path: "/" });
|
|
86
90
|
const txn = txnCookie ? await openData(txnCookie, config.sessionSecret) : null;
|
|
87
|
-
if (!code || !state || !txn || txn.s !== state) {
|
|
91
|
+
if (!code || !state || !txn || txn.s !== state || !txn.n) {
|
|
88
92
|
return failRedirect(
|
|
89
93
|
c,
|
|
90
94
|
"transaction_invalid",
|
|
@@ -111,6 +115,22 @@ function godxSso(getConfig) {
|
|
|
111
115
|
`\u30C8\u30FC\u30AF\u30F3\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u30B5\u30FC\u30D3\u30B9\u8A2D\u5B9A\uFF08issuer / service_slug\uFF09\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u8A73\u7D30: ${e instanceof Error ? e.message : String(e)}`
|
|
112
116
|
);
|
|
113
117
|
}
|
|
118
|
+
try {
|
|
119
|
+
if (!tokens.id_token) throw new Error("the token response has no id_token (is `openid` in scope?)");
|
|
120
|
+
const claims = await verifyIdToken(tokens.id_token, {
|
|
121
|
+
issuer: config.issuer,
|
|
122
|
+
clientId: config.clientId,
|
|
123
|
+
nonce: txn.n,
|
|
124
|
+
http: config.http
|
|
125
|
+
});
|
|
126
|
+
if (claims.sub !== verified.sub) throw new Error("ID token and access token subjects differ");
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return failRedirect(
|
|
129
|
+
c,
|
|
130
|
+
"id_token_invalid",
|
|
131
|
+
`ID \u30C8\u30FC\u30AF\u30F3\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u3082\u3046\u4E00\u5EA6\u30B5\u30A4\u30F3\u30A4\u30F3\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u8A73\u7D30: ${e instanceof Error ? e.message : String(e)}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
114
134
|
let principal = verified;
|
|
115
135
|
try {
|
|
116
136
|
const info = await client(c).userinfo(tokens.access_token);
|
package/dist/hono.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/hono.ts"],"sourcesContent":["/**\n * ./hono — thin adapter over ./auth + ./http for Hono apps on Workers.\n * Mounts the browser SSO routes and exposes the auth middlewares.\n * Everything platform-shaped stays in the core modules.\n */\nimport { Hono } from \"hono\";\nimport type { Context, Env as HonoEnv, MiddlewareHandler } from \"hono\";\nimport { deleteCookie, getCookie, setCookie } from \"hono/cookie\";\nimport { createPkce, openData, randomToken, sealData, verifyAccessToken } from \"./auth\";\nimport type { Principal } from \"./auth\";\nimport { GodxSsoClient, loadIssuer, type HttpOptions, platformFetch } from \"./http\";\n\nexport interface GodxSsoConfig {\n issuer: string;\n serviceSlug: string;\n clientId: string;\n clientSecret?: string;\n redirectUri: string;\n sessionSecret: string;\n /**\n * Requested scopes. Default `openid profile email offline_access`: `offline_access` gets a\n * refresh token, so a session outlives one access token (see `POST <mount>/refresh`).\n */\n scope?: string;\n /**\n * How long a session with a refresh token may live, in seconds (default 30 days = the\n * platform's refresh-token lifetime). The access token inside is still short-lived.\n */\n sessionMaxAge?: number;\n /** Token audience = your ServiceInstance slug (defaults to serviceSlug). */\n audience?: string;\n /**\n * console_organization_id to fall back on when the login was not started\n * from a platform launch URL (which carries organization_context_id).\n * Useful in local dev; leave unset in production multi-org setups.\n */\n defaultOrganizationContextId?: string;\n /** Headers, deadline and fetch for every Platform call this middleware makes (discovery, JWKS, tokens, userinfo, Dock proxy). */\n http?: HttpOptions;\n}\n\n/** p = verified principal, at = access token, rt = refresh token (offline_access). */\ntype SessionData = { p: Principal; at: string; rt?: string };\n\nconst DEFAULT_SCOPE = \"openid profile email offline_access\";\nconst DEFAULT_SESSION_MAX_AGE = 30 * 24 * 3600;\n\nconst TXN_COOKIE = \"gx_txn\";\n\n/**\n * `return_to` for `/login`: only a same-origin path (\"/mail?x=1\") is kept — anything that a\n * browser could resolve to another origin (\"//evil\", \"/\\\\evil\", \"https://…\", control\n * characters) is dropped, so the login flow can never become an open redirect.\n */\nexport function safeReturnTo(raw: string | undefined | null): string | undefined {\n if (!raw || raw.length > 2048 || !/^\\/(?![/\\\\])/.test(raw) || /[\\u0000-\\u001f\\u007f\\\\]/.test(raw)) {\n return undefined;\n }\n const base = \"https://return-to.invalid\";\n const url = new URL(raw, base);\n return url.origin === base ? url.pathname + url.search + url.hash : undefined;\n}\n\nconst SESSION_COOKIE = \"gx_sess\";\n\nexport type GodxVariables = { principal: Principal; accessToken: string };\n\nexport function godxSso<E extends HonoEnv & { Variables: GodxVariables }>(\n getConfig: (env: E[\"Bindings\"]) => GodxSsoConfig,\n) {\n const client = (c: Context<E>) => new GodxSsoClient(getConfig(c.env));\n const cfg = (c: Context<E>) => getConfig(c.env);\n const secure = (c: Context<E>) => c.req.url.startsWith(\"https:\");\n\n /**\n * With a refresh token the cookie lives as long as the refresh token may (the access token\n * inside still expires on time — requireAuth checks it); without one, exactly as long as the\n * access token, as before.\n */\n const writeSession = async (c: Context<E>, data: SessionData) => {\n const config = cfg(c);\n const ttl = data.rt\n ? (config.sessionMaxAge ?? DEFAULT_SESSION_MAX_AGE)\n : Math.max(0, data.p.expiresAt - Math.floor(Date.now() / 1000));\n setCookie(c, SESSION_COOKIE, await sealData(data, config.sessionSecret, ttl), {\n httpOnly: true,\n secure: secure(c),\n sameSite: \"Lax\",\n path: \"/\",\n maxAge: ttl,\n });\n };\n\n const routes = new Hono<E>();\n\n routes.get(\"/login\", async (c) => {\n const config = cfg(c);\n const pkce = await createPkce();\n const state = randomToken();\n const nonce = randomToken();\n // The platform launch URL carries organization_context_id; forward it, or\n // fall back to the configured default (single-org / local dev).\n const organizationContextId =\n c.req.query(\"organization_context_id\") ?? config.defaultOrganizationContextId;\n // Where to land after the callback (an expired session re-signs in on the page it was on).\n const returnTo = safeReturnTo(c.req.query(\"return_to\"));\n setCookie(c, TXN_COOKIE, await sealData({ v: pkce.verifier, s: state, r: returnTo }, config.sessionSecret, 300), {\n httpOnly: true,\n secure: secure(c),\n sameSite: \"Lax\",\n path: \"/\",\n maxAge: 300,\n });\n return c.redirect(\n await client(c).authorizeUrl({\n state,\n nonce,\n codeChallenge: pkce.challenge,\n scope: config.scope ?? DEFAULT_SCOPE,\n organizationContextId,\n }),\n );\n });\n\n // Every failure lands back on the app with a human-readable description —\n // the SPA renders it; a bare 400 helps nobody.\n const failRedirect = (c: Context<E>, error: string, description: string) =>\n c.redirect(\n `/?sso_error=${encodeURIComponent(error)}&sso_error_description=${encodeURIComponent(description)}`,\n );\n\n routes.get(\"/callback\", async (c) => {\n const config = cfg(c);\n\n // The platform denied the launch (access_denied etc.) and already wrote a\n // localized, human-readable explanation into error_description. Show it.\n const platformError = c.req.query(\"error\");\n if (platformError) {\n return failRedirect(\n c,\n platformError,\n c.req.query(\"error_description\") ?? `サインインが拒否されました(${platformError})。`,\n );\n }\n\n const code = c.req.query(\"code\");\n const state = c.req.query(\"state\");\n const txnCookie = getCookie(c, TXN_COOKIE);\n deleteCookie(c, TXN_COOKIE, { path: \"/\" });\n\n const txn = txnCookie ? await openData<{ v: string; s: string; r?: string }>(txnCookie, config.sessionSecret) : null;\n if (!code || !state || !txn || txn.s !== state) {\n return failRedirect(\n c,\n \"transaction_invalid\",\n \"ログイン処理の有効期限が切れたか、途中で中断されました。もう一度サインインしてください。\",\n );\n }\n\n let tokens;\n try {\n tokens = await client(c).exchangeCode({ code, codeVerifier: txn.v });\n } catch (e) {\n return failRedirect(\n c,\n \"token_exchange_failed\",\n `認可コードの交換に失敗しました(認可コードは一度しか使えません)。もう一度サインインしてください。詳細: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n\n let verified;\n try {\n verified = await verifyAccessToken(tokens.access_token, config);\n } catch (e) {\n return failRedirect(\n c,\n \"token_verification_failed\",\n `トークンの検証に失敗しました。サービス設定(issuer / service_slug)を確認してください。詳細: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n\n // One userinfo call at login so the UI has a display name without further round-trips.\n let principal = verified;\n try {\n const info = await client(c).userinfo(tokens.access_token);\n principal = Object.freeze({ ...verified, name: info.name, email: info.email });\n } catch {\n // profile display data is optional; the verified principal stands on its own\n }\n\n await writeSession(c, { p: principal, at: tokens.access_token, rt: tokens.refresh_token });\n return c.redirect(safeReturnTo(txn.r) ?? \"/\");\n });\n\n /**\n * Renews an expired (or expiring) session with its refresh token. The platform ROTATES refresh\n * tokens and revokes the whole family when an old one is replayed, so this is a dedicated route\n * the browser calls ONCE (single-flight) when an API answers 401 `refreshable: true`, then\n * retries — refreshing inside `requireAuth` would let parallel requests replay the same token\n * and log the user out.\n *\n * 204 = renewed (new cookie set). 401 = no refresh token, or the platform refused it (expired,\n * revoked, membership gone): the session cookie is cleared and the app should sign in again.\n */\n routes.post(\"/refresh\", async (c) => {\n const config = cfg(c);\n const raw = getCookie(c, SESSION_COOKIE);\n const session = raw ? await openData<SessionData>(raw, config.sessionSecret) : null;\n if (!session?.rt) return c.json({ error: \"unauthenticated\", refreshable: false }, 401);\n try {\n const tokens = await client(c).refresh(session.rt);\n const verified = await verifyAccessToken(tokens.access_token, config);\n // Display data came from userinfo at login; the same person, so it carries over.\n const principal = Object.freeze({ ...verified, name: session.p.name, email: session.p.email });\n await writeSession(c, { p: principal, at: tokens.access_token, rt: tokens.refresh_token ?? session.rt });\n return c.body(null, 204);\n } catch (e) {\n // The platform's reason (invalid_grant, a revoked family, …) is the only clue an operator gets.\n console.warn(\"godx-sso: refresh failed\", e instanceof Error ? e.message : String(e));\n deleteCookie(c, SESSION_COOKIE, { path: \"/\" });\n return c.json({ error: \"unauthenticated\", refreshable: false }, 401);\n }\n });\n\n routes.get(\"/logout\", async (c) => {\n const config = cfg(c);\n const raw = getCookie(c, SESSION_COOKIE);\n deleteCookie(c, SESSION_COOKIE, { path: \"/\" });\n if (raw) {\n const session = await openData<SessionData>(raw, config.sessionSecret);\n if (session) {\n await client(c).revoke(session.at).catch(() => {});\n // The refresh token outlives the access token by weeks: it must not survive a sign-out.\n if (session.rt) await client(c).revoke(session.rt).catch(() => {});\n }\n }\n const { metadata } = await loadIssuer(config.issuer, config.http);\n return c.redirect(metadata.end_session_endpoint ?? \"/\");\n });\n\n const requireAuth: MiddlewareHandler<E> = async (c, next) => {\n const raw = getCookie(c, SESSION_COOKIE);\n const session = raw ? await openData<SessionData>(raw, cfg(c).sessionSecret) : null;\n if (!session || session.p.expiresAt <= Math.floor(Date.now() / 1000)) {\n // `refreshable`: POST <mount>/refresh can renew it without a new sign-in.\n return c.json({ error: \"unauthenticated\", refreshable: !!session?.rt }, 401);\n }\n c.set(\"principal\", session.p);\n c.set(\"accessToken\", session.at);\n await next();\n };\n\n /**\n * Forwards /api/dock/v1/* to the platform with the session's Bearer token —\n * the GoDX Dock's `apiBase` lane: no CORS, no token in the page. Mount it\n * BEHIND requireAuth and BEFORE any requireWritable (Dock actions are the\n * platform's calls to authorize, not this app's writes).\n */\n const dockProxy: MiddlewareHandler<E> = async (c) => {\n const config = cfg(c);\n const url = new URL(c.req.url);\n const target = new URL(url.pathname + url.search, config.issuer);\n const method = c.req.method;\n const res = await platformFetch(target, {\n method,\n headers: {\n Authorization: `Bearer ${c.get(\"accessToken\")}`,\n Accept: \"application/json\",\n \"Content-Type\": c.req.header(\"Content-Type\") ?? \"application/json\",\n },\n body: method === \"GET\" || method === \"HEAD\" ? undefined : await c.req.arrayBuffer(),\n }, config.http);\n const headers = new Headers({ \"Content-Type\": res.headers.get(\"Content-Type\") ?? \"application/json\" });\n const requestId = res.headers.get(\"X-Request-Id\");\n if (requestId) headers.set(\"X-Request-Id\", requestId);\n return new Response(res.body, { status: res.status, headers });\n };\n\n /** Enforces organization_access_mode: read-only orgs may not write. */\n const requireWritable: MiddlewareHandler<E> = async (c, next) => {\n const safe = c.req.method === \"GET\" || c.req.method === \"HEAD\" || c.req.method === \"OPTIONS\";\n if (!safe && c.get(\"principal\")?.accessMode !== \"full\") {\n return c.json({ error: \"organization is read-only\" }, 403);\n }\n await next();\n };\n\n return { routes, requireAuth, requireWritable, dockProxy };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/hono.ts"],"sourcesContent":["/**\n * ./hono — thin adapter over ./auth + ./http for Hono apps on Workers.\n * Mounts the browser SSO routes and exposes the auth middlewares.\n * Everything platform-shaped stays in the core modules.\n */\nimport { Hono } from \"hono\";\nimport type { Context, Env as HonoEnv, MiddlewareHandler } from \"hono\";\nimport { deleteCookie, getCookie, setCookie } from \"hono/cookie\";\nimport { createPkce, openData, randomToken, sealData, verifyAccessToken, verifyIdToken } from \"./auth\";\nimport type { Principal } from \"./auth\";\nimport { GodxSsoClient, loadIssuer, type HttpOptions, platformFetch } from \"./http\";\n\nexport interface GodxSsoConfig {\n issuer: string;\n /** Catalog service slug. OMIT it for a mini app — see SsoClientConfig.serviceSlug. */\n serviceSlug?: string;\n clientId: string;\n clientSecret?: string;\n redirectUri: string;\n sessionSecret: string;\n /**\n * Requested scopes. Default `openid profile email offline_access`: `offline_access` gets a\n * refresh token, so a session outlives one access token (see `POST <mount>/refresh`).\n */\n scope?: string;\n /**\n * How long a session with a refresh token may live, in seconds (default 30 days = the\n * platform's refresh-token lifetime). The access token inside is still short-lived.\n */\n sessionMaxAge?: number;\n /**\n * Token audience = your ServiceInstance slug (defaults to serviceSlug). A mini app (no\n * serviceSlug) defaults to its clientId — the Platform mints mini-app access tokens for it.\n */\n audience?: string;\n /**\n * console_organization_id to fall back on when the login was not started\n * from a platform launch URL (which carries organization_context_id).\n * Useful in local dev; leave unset in production multi-org setups.\n */\n defaultOrganizationContextId?: string;\n /** Headers, deadline and fetch for every Platform call this middleware makes (discovery, JWKS, tokens, userinfo, Dock proxy). */\n http?: HttpOptions;\n}\n\n/** p = verified principal, at = access token, rt = refresh token (offline_access). */\ntype SessionData = { p: Principal; at: string; rt?: string };\n\nconst DEFAULT_SCOPE = \"openid profile email offline_access\";\nconst DEFAULT_SESSION_MAX_AGE = 30 * 24 * 3600;\n\nconst TXN_COOKIE = \"gx_txn\";\n\n/**\n * `return_to` for `/login`: only a same-origin path (\"/mail?x=1\") is kept — anything that a\n * browser could resolve to another origin (\"//evil\", \"/\\\\evil\", \"https://…\", control\n * characters) is dropped, so the login flow can never become an open redirect.\n */\nexport function safeReturnTo(raw: string | undefined | null): string | undefined {\n if (!raw || raw.length > 2048 || !/^\\/(?![/\\\\])/.test(raw) || /[\\u0000-\\u001f\\u007f\\\\]/.test(raw)) {\n return undefined;\n }\n const base = \"https://return-to.invalid\";\n const url = new URL(raw, base);\n return url.origin === base ? url.pathname + url.search + url.hash : undefined;\n}\n\nconst SESSION_COOKIE = \"gx_sess\";\n\nexport type GodxVariables = { principal: Principal; accessToken: string };\n\nexport function godxSso<E extends HonoEnv & { Variables: GodxVariables }>(\n getConfig: (env: E[\"Bindings\"]) => GodxSsoConfig,\n) {\n const client = (c: Context<E>) => new GodxSsoClient(cfg(c));\n const cfg = (c: Context<E>) => {\n const config = getConfig(c.env);\n return config.serviceSlug === undefined && config.audience === undefined\n ? { ...config, audience: config.clientId }\n : config;\n };\n const secure = (c: Context<E>) => c.req.url.startsWith(\"https:\");\n\n /**\n * With a refresh token the cookie lives as long as the refresh token may (the access token\n * inside still expires on time — requireAuth checks it); without one, exactly as long as the\n * access token, as before.\n */\n const writeSession = async (c: Context<E>, data: SessionData) => {\n const config = cfg(c);\n const ttl = data.rt\n ? (config.sessionMaxAge ?? DEFAULT_SESSION_MAX_AGE)\n : Math.max(0, data.p.expiresAt - Math.floor(Date.now() / 1000));\n setCookie(c, SESSION_COOKIE, await sealData(data, config.sessionSecret, ttl), {\n httpOnly: true,\n secure: secure(c),\n sameSite: \"Lax\",\n path: \"/\",\n maxAge: ttl,\n });\n };\n\n const routes = new Hono<E>();\n\n routes.get(\"/login\", async (c) => {\n const config = cfg(c);\n const pkce = await createPkce();\n const state = randomToken();\n const nonce = randomToken();\n // The platform launch URL carries organization_context_id; forward it, or\n // fall back to the configured default (single-org / local dev).\n const organizationContextId =\n c.req.query(\"organization_context_id\") ?? config.defaultOrganizationContextId;\n // Where to land after the callback (an expired session re-signs in on the page it was on).\n const returnTo = safeReturnTo(c.req.query(\"return_to\"));\n setCookie(c, TXN_COOKIE, await sealData({ v: pkce.verifier, s: state, n: nonce, r: returnTo }, config.sessionSecret, 300), {\n httpOnly: true,\n secure: secure(c),\n sameSite: \"Lax\",\n path: \"/\",\n maxAge: 300,\n });\n return c.redirect(\n await client(c).authorizeUrl({\n state,\n nonce,\n codeChallenge: pkce.challenge,\n scope: config.scope ?? DEFAULT_SCOPE,\n organizationContextId,\n }),\n );\n });\n\n // Every failure lands back on the app with a human-readable description —\n // the SPA renders it; a bare 400 helps nobody.\n const failRedirect = (c: Context<E>, error: string, description: string) =>\n c.redirect(\n `/?sso_error=${encodeURIComponent(error)}&sso_error_description=${encodeURIComponent(description)}`,\n );\n\n routes.get(\"/callback\", async (c) => {\n const config = cfg(c);\n\n // The platform denied the launch (access_denied etc.) and already wrote a\n // localized, human-readable explanation into error_description. Show it.\n const platformError = c.req.query(\"error\");\n if (platformError) {\n return failRedirect(\n c,\n platformError,\n c.req.query(\"error_description\") ?? `サインインが拒否されました(${platformError})。`,\n );\n }\n\n const code = c.req.query(\"code\");\n const state = c.req.query(\"state\");\n const txnCookie = getCookie(c, TXN_COOKIE);\n deleteCookie(c, TXN_COOKIE, { path: \"/\" });\n\n const txn = txnCookie ? await openData<{ v: string; s: string; n?: string; r?: string }>(txnCookie, config.sessionSecret) : null;\n if (!code || !state || !txn || txn.s !== state || !txn.n) {\n return failRedirect(\n c,\n \"transaction_invalid\",\n \"ログイン処理の有効期限が切れたか、途中で中断されました。もう一度サインインしてください。\",\n );\n }\n\n let tokens;\n try {\n tokens = await client(c).exchangeCode({ code, codeVerifier: txn.v });\n } catch (e) {\n return failRedirect(\n c,\n \"token_exchange_failed\",\n `認可コードの交換に失敗しました(認可コードは一度しか使えません)。もう一度サインインしてください。詳細: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n\n let verified;\n try {\n verified = await verifyAccessToken(tokens.access_token, config);\n } catch (e) {\n return failRedirect(\n c,\n \"token_verification_failed\",\n `トークンの検証に失敗しました。サービス設定(issuer / service_slug)を確認してください。詳細: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n\n // The ID token must carry the nonce this login sealed into the txn cookie (replay protection),\n // and name the same subject as the access token — php-sdk's BrowserCodeFlow::complete rule.\n try {\n if (!tokens.id_token) throw new Error(\"the token response has no id_token (is `openid` in scope?)\");\n const claims = await verifyIdToken(tokens.id_token, {\n issuer: config.issuer,\n clientId: config.clientId,\n nonce: txn.n,\n http: config.http,\n });\n if (claims.sub !== verified.sub) throw new Error(\"ID token and access token subjects differ\");\n } catch (e) {\n return failRedirect(\n c,\n \"id_token_invalid\",\n `ID トークンの検証に失敗しました。もう一度サインインしてください。詳細: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n\n // One userinfo call at login so the UI has a display name without further round-trips.\n let principal = verified;\n try {\n const info = await client(c).userinfo(tokens.access_token);\n principal = Object.freeze({ ...verified, name: info.name, email: info.email });\n } catch {\n // profile display data is optional; the verified principal stands on its own\n }\n\n await writeSession(c, { p: principal, at: tokens.access_token, rt: tokens.refresh_token });\n return c.redirect(safeReturnTo(txn.r) ?? \"/\");\n });\n\n /**\n * Renews an expired (or expiring) session with its refresh token. The platform ROTATES refresh\n * tokens and revokes the whole family when an old one is replayed, so this is a dedicated route\n * the browser calls ONCE (single-flight) when an API answers 401 `refreshable: true`, then\n * retries — refreshing inside `requireAuth` would let parallel requests replay the same token\n * and log the user out.\n *\n * 204 = renewed (new cookie set). 401 = no refresh token, or the platform refused it (expired,\n * revoked, membership gone): the session cookie is cleared and the app should sign in again.\n */\n routes.post(\"/refresh\", async (c) => {\n const config = cfg(c);\n const raw = getCookie(c, SESSION_COOKIE);\n const session = raw ? await openData<SessionData>(raw, config.sessionSecret) : null;\n if (!session?.rt) return c.json({ error: \"unauthenticated\", refreshable: false }, 401);\n try {\n const tokens = await client(c).refresh(session.rt);\n const verified = await verifyAccessToken(tokens.access_token, config);\n // Display data came from userinfo at login; the same person, so it carries over.\n const principal = Object.freeze({ ...verified, name: session.p.name, email: session.p.email });\n await writeSession(c, { p: principal, at: tokens.access_token, rt: tokens.refresh_token ?? session.rt });\n return c.body(null, 204);\n } catch (e) {\n // The platform's reason (invalid_grant, a revoked family, …) is the only clue an operator gets.\n console.warn(\"godx-sso: refresh failed\", e instanceof Error ? e.message : String(e));\n deleteCookie(c, SESSION_COOKIE, { path: \"/\" });\n return c.json({ error: \"unauthenticated\", refreshable: false }, 401);\n }\n });\n\n routes.get(\"/logout\", async (c) => {\n const config = cfg(c);\n const raw = getCookie(c, SESSION_COOKIE);\n deleteCookie(c, SESSION_COOKIE, { path: \"/\" });\n if (raw) {\n const session = await openData<SessionData>(raw, config.sessionSecret);\n if (session) {\n await client(c).revoke(session.at).catch(() => {});\n // The refresh token outlives the access token by weeks: it must not survive a sign-out.\n if (session.rt) await client(c).revoke(session.rt).catch(() => {});\n }\n }\n const { metadata } = await loadIssuer(config.issuer, config.http);\n return c.redirect(metadata.end_session_endpoint ?? \"/\");\n });\n\n const requireAuth: MiddlewareHandler<E> = async (c, next) => {\n const raw = getCookie(c, SESSION_COOKIE);\n const session = raw ? await openData<SessionData>(raw, cfg(c).sessionSecret) : null;\n if (!session || session.p.expiresAt <= Math.floor(Date.now() / 1000)) {\n // `refreshable`: POST <mount>/refresh can renew it without a new sign-in.\n return c.json({ error: \"unauthenticated\", refreshable: !!session?.rt }, 401);\n }\n c.set(\"principal\", session.p);\n c.set(\"accessToken\", session.at);\n await next();\n };\n\n /**\n * Forwards /api/dock/v1/* to the platform with the session's Bearer token —\n * the GoDX Dock's `apiBase` lane: no CORS, no token in the page. Mount it\n * BEHIND requireAuth and BEFORE any requireWritable (Dock actions are the\n * platform's calls to authorize, not this app's writes).\n */\n const dockProxy: MiddlewareHandler<E> = async (c) => {\n const config = cfg(c);\n const url = new URL(c.req.url);\n const target = new URL(url.pathname + url.search, config.issuer);\n const method = c.req.method;\n const res = await platformFetch(target, {\n method,\n headers: {\n Authorization: `Bearer ${c.get(\"accessToken\")}`,\n Accept: \"application/json\",\n \"Content-Type\": c.req.header(\"Content-Type\") ?? \"application/json\",\n },\n body: method === \"GET\" || method === \"HEAD\" ? undefined : await c.req.arrayBuffer(),\n }, config.http);\n const headers = new Headers({ \"Content-Type\": res.headers.get(\"Content-Type\") ?? \"application/json\" });\n const requestId = res.headers.get(\"X-Request-Id\");\n if (requestId) headers.set(\"X-Request-Id\", requestId);\n return new Response(res.body, { status: res.status, headers });\n };\n\n /** Enforces organization_access_mode: read-only orgs may not write. */\n const requireWritable: MiddlewareHandler<E> = async (c, next) => {\n const safe = c.req.method === \"GET\" || c.req.method === \"HEAD\" || c.req.method === \"OPTIONS\";\n if (!safe && c.get(\"principal\")?.accessMode !== \"full\") {\n return c.json({ error: \"organization is read-only\" }, 403);\n }\n await next();\n };\n\n return { routes, requireAuth, requireWritable, dockProxy };\n}\n"],"mappings":"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","names":[]}
|
package/dist/http.d.ts
CHANGED
|
@@ -62,12 +62,17 @@ declare class GodxHttpError extends Error {
|
|
|
62
62
|
declare function loadIssuer(issuerUrl: string, options?: HttpOptions): Promise<Issuer>;
|
|
63
63
|
interface SsoClientConfig {
|
|
64
64
|
issuer: string;
|
|
65
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Catalog service slug, sent as `service_slug`. OMIT it for a mini app (`org_…`/`isv_…`
|
|
67
|
+
* client): the Platform routes to the mini-app lane only when `service_slug` is absent —
|
|
68
|
+
* the same rule as php-sdk's BrowserProfile/BrowserServer (`serviceSlug === null` → omitted).
|
|
69
|
+
*/
|
|
70
|
+
serviceSlug?: string;
|
|
66
71
|
clientId: string;
|
|
67
72
|
/** Omit for a public (secretless) instance — PKCE alone then. */
|
|
68
73
|
clientSecret?: string;
|
|
69
74
|
redirectUri: string;
|
|
70
|
-
/** Token audience = the ServiceInstance slug (defaults to serviceSlug). */
|
|
75
|
+
/** Token audience = the ServiceInstance slug (defaults to serviceSlug; a mini app: its client_id). */
|
|
71
76
|
audience?: string;
|
|
72
77
|
/** Headers, deadline and fetch for every request this client makes. */
|
|
73
78
|
http?: HttpOptions;
|
|
@@ -109,6 +114,7 @@ declare class GodxSsoClient {
|
|
|
109
114
|
userinfo(accessToken: string): Promise<UserinfoResponse>;
|
|
110
115
|
private tokenRequest;
|
|
111
116
|
private postForm;
|
|
117
|
+
private serviceSlugField;
|
|
112
118
|
}
|
|
113
119
|
/**
|
|
114
120
|
* Server-to-server reads with service credentials (X-Service-Slug/X-Service-Secret).
|
package/dist/http.js
CHANGED
package/dist/org.js
CHANGED
package/dist/sync.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@godxjp/server-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "GoDX server-side SDK (Workers/Node) — holds service credentials; for browser code use @godxjp/web-sdk. Counterpart of godx-jp/php-sdk.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/http.ts"],"sourcesContent":["/**\n * ./http — endpoint contract from OIDC discovery, the SSO client, and the\n * server-to-server service API. Runtime-agnostic: fetch + WebCrypto only.\n * Mirrors php-sdk's Http module boundary.\n */\nimport { createRemoteJWKSet, customFetch } from \"jose\";\n\n/**\n * Per-call knobs for every outbound request this SDK makes (godx-jp/server-sdk#17, from the\n * pages.godx.jp incident of 2026-10-04 where a Platform call hung 25–180 s with no request id\n * to find it by). Pass them on the client/config (`http`) or per call where a function takes\n * them; defaults are exactly the old behaviour.\n */\nexport interface HttpOptions {\n /**\n * Extra headers on every request — `X-Request-Id` is the one that makes a hang findable in\n * Platform's log. A function is called per request (mint a fresh id each time); a header the\n * SDK itself sets (Authorization, Content-Type, X-Service-*) always wins.\n */\n headers?: Record<string, string> | (() => Record<string, string>);\n /** Abort a request after this many milliseconds and throw {@link GodxTimeoutError}. Unset = the runtime's default (often none). */\n timeoutMs?: number;\n /** The fetch to use (tests, custom dispatchers). Default `globalThis.fetch`, read at call time. */\n fetch?: typeof fetch;\n}\n\n/** The request did not answer within `timeoutMs`; `url` and `timeoutMs` say which and how long. */\nexport class GodxTimeoutError extends Error {\n readonly url: string;\n readonly timeoutMs: number;\n constructor(url: string, timeoutMs: number) {\n super(`GoDX platform request to ${url} did not answer within ${timeoutMs} ms`);\n this.name = \"GodxTimeoutError\";\n this.url = url;\n this.timeoutMs = timeoutMs;\n }\n}\n\n/**\n * `fetch` with {@link HttpOptions} applied: merged headers, a deadline, an injectable fetch.\n * Every outbound call in this package goes through here, so one option reaches all of them.\n */\nexport async function platformFetch(\n input: URL | string,\n init: RequestInit = {},\n options: HttpOptions = {},\n): Promise<Response> {\n const headers = new Headers(init.headers);\n const extra = typeof options.headers === \"function\" ? options.headers() : options.headers;\n for (const [name, value] of Object.entries(extra ?? {})) {\n if (!headers.has(name)) headers.set(name, value);\n }\n const doFetch = options.fetch ?? globalThis.fetch;\n if (options.timeoutMs === undefined || options.timeoutMs <= 0) {\n return doFetch(input, { ...init, headers });\n }\n const controller = new AbortController();\n let timedOut = false;\n const timer = setTimeout(() => {\n timedOut = true;\n controller.abort();\n }, options.timeoutMs);\n init.signal?.addEventListener(\"abort\", () => controller.abort(), { once: true });\n try {\n return await doFetch(input, { ...init, headers, signal: controller.signal });\n } catch (error) {\n if (timedOut) throw new GodxTimeoutError(String(input), options.timeoutMs);\n throw error;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface IssuerMetadata {\n issuer: string;\n authorization_endpoint: string;\n token_endpoint: string;\n userinfo_endpoint: string;\n jwks_uri: string;\n end_session_endpoint?: string;\n revocation_endpoint?: string;\n}\n\nexport interface Issuer {\n metadata: IssuerMetadata;\n jwks: ReturnType<typeof createRemoteJWKSet>;\n}\n\nexport class GodxHttpError extends Error {\n readonly status: number;\n readonly body: string;\n\n constructor(status: number, body: string, message?: string) {\n super(message ?? `GoDX platform request failed with ${status}`);\n this.name = \"GodxHttpError\";\n this.status = status;\n this.body = body;\n }\n}\n\n// Per-isolate cache; the platform docs require reading discovery, not hardcoding paths.\nconst issuerCache = new Map<string, Promise<Issuer>>();\n\n/**\n * Discovery + JWKS, cached per isolate and issuer. `options` applies to the discovery request and\n * to every JWKS refresh of the cached set (`timeoutMs` → jose `timeoutDuration`; static headers\n * only — a header FUNCTION cannot run inside jose's refresh, so it is evaluated once here). The\n * first call for an issuer decides the JWKS options; a failed load evicts itself.\n */\nexport function loadIssuer(issuerUrl: string, options: HttpOptions = {}): Promise<Issuer> {\n let cached = issuerCache.get(issuerUrl);\n if (!cached) {\n cached = (async () => {\n const res = await platformFetch(new URL(\"/.well-known/openid-configuration\", issuerUrl), {}, options);\n if (!res.ok) {\n issuerCache.delete(issuerUrl);\n throw new GodxHttpError(res.status, await res.text(), \"OIDC discovery failed\");\n }\n const metadata = (await res.json()) as IssuerMetadata;\n const jwksHeaders = typeof options.headers === \"function\" ? options.headers() : options.headers;\n const jwks = createRemoteJWKSet(new URL(metadata.jwks_uri), {\n ...(options.timeoutMs ? { timeoutDuration: options.timeoutMs } : {}),\n ...(jwksHeaders ? { headers: jwksHeaders } : {}),\n // jose keys its fetch override by its OWN exported symbol (a plain Symbol(), not\n // Symbol.for): 0.10.0 passed Symbol.for(\"jose.fetch\") and the JWKS fetch silently ignored\n // `http.fetch` — caught by pages' lead (godx-jp/server-sdk#19).\n ...(options.fetch ? { [customFetch]: options.fetch } : {}),\n });\n return { metadata, jwks };\n })();\n issuerCache.set(issuerUrl, cached);\n cached.catch(() => issuerCache.delete(issuerUrl));\n }\n return cached;\n}\n\nexport interface SsoClientConfig {\n issuer: string;\n serviceSlug: string;\n clientId: string;\n /** Omit for a public (secretless) instance — PKCE alone then. */\n clientSecret?: string;\n redirectUri: string;\n /** Token audience = the ServiceInstance slug (defaults to serviceSlug). */\n audience?: string;\n /** Headers, deadline and fetch for every request this client makes. */\n http?: HttpOptions;\n}\n\nexport interface TokenResponse {\n access_token: string;\n token_type: string;\n expires_in: number;\n id_token?: string;\n refresh_token?: string;\n scope?: string;\n}\n\nexport interface UserinfoResponse {\n sub: string;\n email?: string;\n email_verified?: boolean;\n name?: string;\n locale?: string;\n timezone?: string;\n}\n\nexport class GodxSsoClient {\n readonly config: SsoClientConfig;\n\n constructor(config: SsoClientConfig) {\n this.config = config;\n }\n\n async authorizeUrl(params: {\n state: string;\n nonce: string;\n codeChallenge: string;\n scope?: string;\n /** console_organization_id — required by the launch gates; usually arrives on the launch URL. */\n organizationContextId?: string;\n }): Promise<string> {\n const { metadata } = await loadIssuer(this.config.issuer, this.config.http);\n const url = new URL(metadata.authorization_endpoint);\n url.search = new URLSearchParams({\n service_slug: this.config.serviceSlug,\n client_id: this.config.clientId,\n redirect_uri: this.config.redirectUri,\n response_type: \"code\",\n scope: params.scope ?? \"openid profile email\",\n state: params.state,\n nonce: params.nonce,\n code_challenge: params.codeChallenge,\n code_challenge_method: \"S256\",\n ...(params.organizationContextId\n ? { organization_context_id: params.organizationContextId }\n : {}),\n }).toString();\n return url.toString();\n }\n\n async exchangeCode(params: { code: string; codeVerifier: string }): Promise<TokenResponse> {\n return this.tokenRequest(\"token_endpoint\", {\n grant_type: \"authorization_code\",\n redirect_uri: this.config.redirectUri,\n code: params.code,\n code_verifier: params.codeVerifier,\n });\n }\n\n async refresh(refreshToken: string): Promise<TokenResponse> {\n const { metadata } = await loadIssuer(this.config.issuer, this.config.http);\n return this.postForm(new URL(\"/api/sso/refresh\", metadata.issuer).toString(), {\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n // The platform files a refresh token under the INSTANCE slug (the token's audience,\n // e.g. \"godx-logger-prod\"), not the catalog service slug: sending the service slug\n // finds no row and answers invalid_grant (measured against id.godx.jp).\n service_slug: this.config.audience ?? this.config.serviceSlug,\n });\n }\n\n /** Best-effort revocation; failures are the caller's decision to ignore. */\n async revoke(token: string): Promise<void> {\n const { metadata } = await loadIssuer(this.config.issuer, this.config.http);\n if (!metadata.revocation_endpoint) return;\n await this.postForm(metadata.revocation_endpoint, { token });\n }\n\n async userinfo(accessToken: string): Promise<UserinfoResponse> {\n const { metadata } = await loadIssuer(this.config.issuer, this.config.http);\n const res = await platformFetch(metadata.userinfo_endpoint, {\n headers: { Authorization: `Bearer ${accessToken}` },\n }, this.config.http);\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), \"userinfo failed\");\n return (await res.json()) as UserinfoResponse;\n }\n\n private async tokenRequest(\n endpoint: \"token_endpoint\",\n fields: Record<string, string>,\n ): Promise<TokenResponse> {\n const { metadata } = await loadIssuer(this.config.issuer, this.config.http);\n return this.postForm(metadata[endpoint], fields);\n }\n\n private async postForm(url: string, fields: Record<string, string>): Promise<TokenResponse> {\n const body = new URLSearchParams({\n service_slug: this.config.serviceSlug,\n client_id: this.config.clientId,\n ...(this.config.clientSecret ? { client_secret: this.config.clientSecret } : {}),\n ...fields,\n });\n const res = await platformFetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body,\n }, this.config.http);\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), \"token request failed\");\n return (await res.json()) as TokenResponse;\n }\n}\n\n/**\n * Server-to-server reads with service credentials (X-Service-Slug/X-Service-Secret).\n * Rate-limited by the platform (429) — cache results and back off; no auto-retry here.\n */\nexport function createServiceApi(config: {\n issuer: string;\n serviceSlug: string;\n serviceSecret: string;\n http?: HttpOptions;\n}) {\n const get = async <T>(path: string, query: Record<string, string>): Promise<T> => {\n const url = new URL(path, config.issuer);\n url.search = new URLSearchParams(query).toString();\n const res = await platformFetch(url, {\n headers: {\n \"X-Service-Slug\": config.serviceSlug,\n \"X-Service-Secret\": config.serviceSecret,\n },\n }, config.http);\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), `service API ${path} failed`);\n return (await res.json()) as T;\n };\n\n return {\n users: (q: { organizationSlug: string; perPage?: number; page?: number }) =>\n get<unknown>(\"/api/sso/service/users\", {\n organization_slug: q.organizationSlug,\n ...(q.perPage ? { per_page: String(q.perPage) } : {}),\n ...(q.page ? { page: String(q.page) } : {}),\n }),\n branches: (q: { organizationSlug: string }) =>\n get<unknown>(\"/api/sso/service/branches\", { organization_slug: q.organizationSlug }),\n brands: (q: { organizationSlug: string }) =>\n get<unknown>(\"/api/sso/service/brands\", { organization_slug: q.organizationSlug }),\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/auth.ts"],"sourcesContent":["/**\n * ./auth — verified immutable Principal, PKCE, and sealed session values.\n * Mirrors php-sdk's Auth module: the Principal only comes out of one parse\n * gate (verifyAccessToken) and is frozen after that.\n */\nimport { EncryptJWT, jwtDecrypt, jwtVerify } from \"jose\";\nimport { type HttpOptions, loadIssuer } from \"./http\";\n\nexport interface Principal {\n readonly sub: string;\n /** Claim organization_id — the organization's INTERNAL id. */\n readonly organizationId: string;\n /**\n * Claim organization_context_id — the CONSOLE organization id, when the token carries one.\n * This is the id the platform's organization-scoped APIs look organizations up by\n * (`/api/sso/me/permissions`, `fetchEffectivePermissions`); passing `organizationId` there\n * answers 403 CONTEXT_UNAVAILABLE (godx-jp/server-sdk#6).\n */\n readonly organizationContextId?: string;\n /** Defaults to read_only unless the claim says full — fail closed. */\n readonly accessMode: \"full\" | \"read_only\";\n readonly scopes: readonly string[];\n readonly serviceInstanceId?: string;\n readonly clientId?: string;\n /** Unix seconds, from the access token's exp. */\n readonly expiresAt: number;\n /** From userinfo, when the adapter fetched it at login. */\n readonly name?: string;\n readonly email?: string;\n}\n\n/**\n * The one parse gate. Validates signature (JWKS), iss (discovery issuer,\n * exact), aud (= service slug), exp — then requires organization_id.\n */\nexport async function verifyAccessToken(\n token: string,\n opts: {\n issuer: string;\n serviceSlug: string;\n /** The token audience = your ServiceInstance slug. Defaults to serviceSlug. */\n audience?: string;\n /** Headers, deadline and fetch for discovery and JWKS (first call per issuer decides the JWKS options). */\n http?: HttpOptions;\n },\n): Promise<Principal> {\n const expectedAudience = opts.audience ?? opts.serviceSlug;\n const { metadata, jwks } = await loadIssuer(opts.issuer, opts.http);\n let payload;\n try {\n ({ payload } = await jwtVerify(token, jwks, {\n issuer: metadata.issuer,\n audience: expectedAudience,\n }));\n } catch (e) {\n // Say what was expected vs what the token actually carries — a bare\n // \"unexpected claim\" costs whoever reads it a debugging session.\n const actual = decodeUnverified(token);\n throw new Error(\n `${e instanceof Error ? e.message : String(e)} — expected iss=\"${metadata.issuer}\" aud=\"${expectedAudience}\", ` +\n `token carries iss=\"${String(actual?.iss ?? \"?\")}\" aud=${JSON.stringify(actual?.aud ?? \"?\")}`,\n { cause: e },\n );\n }\n\n const organizationId = payload[\"organization_id\"];\n if (typeof organizationId !== \"string\" || organizationId === \"\") {\n throw new Error(\"access token has no organization_id claim\");\n }\n if (typeof payload.sub !== \"string\" || typeof payload.exp !== \"number\") {\n throw new Error(\"access token is missing sub/exp\");\n }\n\n return Object.freeze({\n sub: payload.sub,\n organizationId,\n organizationContextId:\n typeof payload[\"organization_context_id\"] === \"string\" && payload[\"organization_context_id\"] !== \"\"\n ? payload[\"organization_context_id\"]\n : undefined,\n accessMode: payload[\"organization_access_mode\"] === \"full\" ? \"full\" : \"read_only\",\n scopes: Object.freeze(String(payload[\"scope\"] ?? \"\").split(\" \").filter(Boolean)),\n serviceInstanceId: typeof payload[\"service_instance_id\"] === \"string\" ? payload[\"service_instance_id\"] : undefined,\n clientId: typeof payload[\"client_id\"] === \"string\" ? payload[\"client_id\"] : undefined,\n expiresAt: payload.exp,\n } satisfies Principal);\n}\n\nexport interface Pkce {\n verifier: string;\n challenge: string;\n method: \"S256\";\n}\n\nexport async function createPkce(): Promise<Pkce> {\n const verifier = randomToken(32);\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(verifier));\n return { verifier, challenge: base64url(new Uint8Array(digest)), method: \"S256\" };\n}\n\nexport function randomToken(bytes = 16): string {\n const buf = new Uint8Array(bytes);\n crypto.getRandomValues(buf);\n return base64url(buf);\n}\n\n/** Diagnostics only — an UNVERIFIED read of the payload for error messages. */\nfunction decodeUnverified(token: string): Record<string, unknown> | null {\n try {\n const part = token.split(\".\")[1] ?? \"\";\n const pad = part.replace(/-/g, \"+\").replace(/_/g, \"/\");\n return JSON.parse(atob(pad)) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let bin = \"\";\n for (const b of bytes) bin += String.fromCharCode(b);\n return btoa(bin).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function sessionKey(secret: string): Promise<Uint8Array> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(secret));\n return new Uint8Array(digest);\n}\n\n/** Encrypt + sign an arbitrary payload into a compact cookie value (JWE dir/A256GCM). */\nexport async function sealData(\n payload: Record<string, unknown>,\n secret: string,\n ttlSeconds: number,\n): Promise<string> {\n return new EncryptJWT(payload)\n .setProtectedHeader({ alg: \"dir\", enc: \"A256GCM\" })\n .setIssuedAt()\n .setExpirationTime(Math.floor(Date.now() / 1000) + ttlSeconds)\n .encrypt(await sessionKey(secret));\n}\n\n/** Returns null for anything invalid or expired — never throws on bad input. */\nexport async function openData<T = Record<string, unknown>>(\n token: string,\n secret: string,\n): Promise<T | null> {\n try {\n const { payload } = await jwtDecrypt(token, await sessionKey(secret));\n return payload as T;\n } catch {\n return null;\n }\n}\n"],"mappings":"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","names":[]}
|