@godxjp/server-sdk 0.11.0 → 0.13.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 +94 -7
- package/dist/auth.d.ts +17 -3
- package/dist/auth.js +6 -4
- package/dist/authorization.js +1 -1
- package/dist/{chunk-2N6BMNKT.js → chunk-DUFW6IQK.js} +9 -3
- package/dist/chunk-DUFW6IQK.js.map +1 -0
- package/dist/{chunk-K3AG5TCN.js → chunk-SXH6AIYT.js} +20 -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/notifications.d.ts +269 -0
- package/dist/notifications.js +418 -0
- package/dist/notifications.js.map +1 -0
- package/dist/org.js +1 -1
- package/dist/sync.js +1 -1
- package/package.json +8 -1
- package/dist/chunk-2N6BMNKT.js.map +0 -1
- package/dist/chunk-K3AG5TCN.js.map +0 -1
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ Naming is by **side**, not by language (`web-sdk` / `server-sdk` / `php-sdk`)
|
|
|
20
20
|
| `./hono` | Thin adapter: `godxSso()` mounts `/auth/{login,callback,logout}`, `requireAuth`, `requireWritable` (read-only organizations may not write), `dockProxy` (the GoDX Dock's `apiBase` lane — no CORS, no token in the page). Every SSO failure lands back on the app as `/?sso_error=…&sso_error_description=…` with a human-readable description. |
|
|
21
21
|
| `./sync` | The identity feed, both consumer legs — see below. |
|
|
22
22
|
| `./org` | Typed client for the **write-back** surface (`/api/org/v1/*`): me, employees, departments, branches, teams (list / paginate-all / create), and **notifications.create** (godx-jp/id#1962). `Idempotency-Key` on every write (IETF draft), RFC 6750 `WWW-Authenticate` surfaced on 401/403, 422 field errors and 429 `Retry-After` exposed on `OrgApiError`. Tenancy is the token's — the client never sends an organization id. Since godx-jp/id#1960, a downstream **service-launch token carries the `organization-api` audience whenever org.\* scopes are granted** (i.e. survive RBAC narrowing) — see [`./org`](#org--the-write-back-client) below. |
|
|
23
|
+
| `./notifications` | **Notification Center**, one interface for every service (CHAT-65), mirroring php-sdk: an immutable `Notice` checked against Platform's rules (`InvalidNoticeError` names the field), `createNotificationClient` (machine lane, `NotificationError.reason` = php-sdk's `NotificationFailure`), and `handleNoticeAction` for quick-action presses. Shared fixtures `contract/notices` — see [`./notifications`](#notifications--notification-center) below. |
|
|
23
24
|
| `./producer` | The **outbox producer kit** (godx-jp/id#1963 phase 4): `OUTBOX_TABLE_SQL` (D1 DDL), `recordDomainEvent` (write the event next to the state change), `drainOutbox` (relay unpublished rows in sequence order through an injected `publish`). CloudEvents 1.0 envelope mirroring `./sync`'s shape — see [`./producer`](#producer--the-outbox-producer-kit) below. |
|
|
24
25
|
|
|
25
26
|
Core modules depend on `fetch` + WebCrypto only; `hono` is the only framework-shaped subpath.
|
|
@@ -65,26 +66,42 @@ The platform's launch URL carries `organization_context_id`; `/auth/login` forwa
|
|
|
65
66
|
back to `defaultOrganizationContextId` for local dev). A session is bound to the token's
|
|
66
67
|
`organization_id` — an org switch is a new SSO round, never a reused session.
|
|
67
68
|
|
|
69
|
+
**Mini app** (an `org_…` / `isv_…` client): leave `serviceSlug` out. The Platform routes to the
|
|
70
|
+
mini-app lane only when `service_slug` is absent, so the SDK then omits it from authorize, code
|
|
71
|
+
exchange, refresh (sent to the token endpoint as a plain `refresh_token` grant) and revoke — the
|
|
72
|
+
same rule as php-sdk (`serviceSlug === null` → omitted). `audience` defaults to `clientId`, the
|
|
73
|
+
`aud` the Platform mints mini-app access tokens for.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const sso = godxSso<AppEnv>((env) => ({
|
|
77
|
+
issuer: env.GODX_ISSUER,
|
|
78
|
+
clientId: env.GODX_CLIENT_ID, // org_… / isv_…
|
|
79
|
+
clientSecret: env.GODX_CLIENT_SECRET, // mini apps are confidential clients
|
|
80
|
+
redirectUri: `${env.APP_URL}/auth/callback`,
|
|
81
|
+
sessionSecret: env.SESSION_SECRET,
|
|
82
|
+
}));
|
|
83
|
+
```
|
|
84
|
+
|
|
68
85
|
### `GodxSsoConfig` — every field
|
|
69
86
|
|
|
70
87
|
| Field | Required | Default | Notes |
|
|
71
88
|
|---|---|---|---|
|
|
72
89
|
| `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_…`). |
|
|
90
|
+
| `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. |
|
|
91
|
+
| `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
92
|
| `clientSecret` | no | `undefined` | Omit for a public (secretless) instance — PKCE alone then. |
|
|
76
93
|
| `redirectUri` | yes | — | Must match the instance's `allowed_redirect_uris` exactly (scheme, host, port, path). |
|
|
77
94
|
| `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. |
|
|
95
|
+
| `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
96
|
| `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. |
|
|
97
|
+
| `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
98
|
| `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
99
|
|
|
83
100
|
`godxSso(getConfig)` returns `{ routes, requireAuth, requireWritable, dockProxy }`:
|
|
84
101
|
|
|
85
102
|
| Member | Type | Behaviour |
|
|
86
103
|
|---|---|---|
|
|
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=…`. |
|
|
104
|
+
| `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
105
|
| `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
106
|
| `requireWritable` | middleware | Enforces `organization_access_mode`: non-GET/HEAD/OPTIONS from a `read_only` org answers 403. |
|
|
90
107
|
| `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. |
|
|
@@ -122,7 +139,8 @@ Every export per subpath, checked against `src/*.ts`.
|
|
|
122
139
|
|---|---|---|
|
|
123
140
|
| `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. |
|
|
124
141
|
| `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) |
|
|
125
|
-
| `verifyAccessToken` | `(token, {issuer, serviceSlug
|
|
142
|
+
| `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`. |
|
|
143
|
+
| `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) |
|
|
126
144
|
| `Pkce` | `interface { verifier, challenge, method: "S256" }` | A PKCE pair. |
|
|
127
145
|
| `createPkce` | `() => Promise<Pkce>` | S256 challenge via WebCrypto. |
|
|
128
146
|
| `randomToken` | `(bytes = 16) => string` | base64url random token (state, nonce). |
|
|
@@ -170,7 +188,7 @@ The token lives 45 s and is bound to your resource, the approved arguments and t
|
|
|
170
188
|
| `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) |
|
|
171
189
|
| `GodxTimeoutError` | `class extends Error { url, timeoutMs }` | The request did not answer within `timeoutMs`. |
|
|
172
190
|
| `SsoClientConfig` / `TokenResponse` / `UserinfoResponse` | interfaces | Client config and wire shapes. |
|
|
173
|
-
| `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. |
|
|
191
|
+
| `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. |
|
|
174
192
|
| `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. |
|
|
175
193
|
|
|
176
194
|
### `./hono`
|
|
@@ -236,6 +254,73 @@ and the scope→permission mapping.
|
|
|
236
254
|
| `recordDomainEvent` | `(db, {service, organizationId, aggregate, action, data, id?}) => Promise<RecordedDomainEvent>` | Insert one outbox row next to the caller's own write; composes subject/type, validates every token. |
|
|
237
255
|
| `drainOutbox` | `(db, publish, {batch = 100}?) => Promise<{published, remaining}>` | Publish unpublished rows in sequence order; marks `published_at` only after `publish` resolves; stops at the first failure. |
|
|
238
256
|
|
|
257
|
+
## `./notifications` — Notification Center
|
|
258
|
+
|
|
259
|
+
Put a notice in a person's Dock bell and receive their quick-action presses — the same contract as
|
|
260
|
+
php-sdk's `Notifications\Notice`, `Http\NotificationClient` and `Route::godxNoticeActions()`
|
|
261
|
+
(Platform: godx-jp/id `docs/guide/downstream-service-onboarding.md` → Sending notifications).
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { Notice, NotificationError, createNotificationClient, handleNoticeAction, NOTICE_ACTIONS_PATH } from "@godxjp/server-sdk/notifications";
|
|
265
|
+
|
|
266
|
+
const notifications = createNotificationClient({
|
|
267
|
+
issuer: env.GODX_ISSUER,
|
|
268
|
+
serviceSlug: env.GODX_INSTANCE_SLUG, // the INSTANCE slug, never client_id
|
|
269
|
+
serviceSecret: env.GODX_CLIENT_SECRET, // empty → `configured` is false and send() rejects `not_configured`
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const notice = Notice.create({
|
|
273
|
+
recipients: [approverId], // Platform user ids, 1..100
|
|
274
|
+
sourceRef: `approval:task:${taskId}`, // the dedup key: one row per ref per person
|
|
275
|
+
actionUrl: `${baseUrl}/requests/42`, // absolute, under the instance base URL
|
|
276
|
+
title: { ja: "承認依頼", en: "Approval requested" },
|
|
277
|
+
body: { ja: "休暇申請 #42 が届きました。" },
|
|
278
|
+
})
|
|
279
|
+
.withKind("approval")
|
|
280
|
+
.forOrganization(consoleOrganizationId) // pool deployments
|
|
281
|
+
.withActions(
|
|
282
|
+
{ id: "approve", label: { ja: "承認", en: "Approve" }, style: "primary" },
|
|
283
|
+
{ id: "reject", label: { ja: "却下", en: "Reject" }, style: "danger", reason: "required" },
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const { created, updated, skipped } = await notifications.send(notice, { idempotencyKey: `approval:task:${taskId}:1` });
|
|
288
|
+
} catch (e) {
|
|
289
|
+
if (e instanceof NotificationError && e.retryable) { /* rate_limited / unavailable: retry with the SAME key */ }
|
|
290
|
+
throw e;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// The press: POST {base_url}/api/platform/v1/notice-actions
|
|
294
|
+
app.post(NOTICE_ACTIONS_PATH, (c) =>
|
|
295
|
+
handleNoticeAction(c.req.raw, {
|
|
296
|
+
issuer: c.env.GODX_ISSUER,
|
|
297
|
+
audience: c.env.GODX_INSTANCE_SLUG,
|
|
298
|
+
handler: async (press) => {
|
|
299
|
+
// press.principal.subject / .organization come from the token; press.sourceRef, .action, .reason, .locale
|
|
300
|
+
if (press.action !== "approve" && press.action !== "reject") return { state: "invalid" };
|
|
301
|
+
return decide(press); // as that person, under your own rules → done | already_done | denied | invalid
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
- **Send.** `Notice` is immutable (`with…()` returns a new, re-checked one) and `toPayload()` emits only the fields that
|
|
308
|
+
were set — Platform fingerprints the body for its `Idempotency-Key`. `Notice.fromPayload(body)` reads a hand-built body,
|
|
309
|
+
refusing an unknown key or locale that Platform would silently drop. Lengths are counted in code points, as Laravel does.
|
|
310
|
+
- **Failures.** `NotificationError extends GodxHttpError`; `reason` is one of `not_configured`, `unauthenticated`,
|
|
311
|
+
`forbidden` (the lane is off — `notifications_api_enabled` — or the credential is wrong; Platform answers both alike),
|
|
312
|
+
`organization_not_served`, `idempotency_conflict`, `invalid`, `rate_limited`, `unavailable`, `unexpected_response`.
|
|
313
|
+
Like php-sdk, the error body is never read (`body` is `""`): a 422 for one recipient without access refuses the whole
|
|
314
|
+
call and does not say which. Redirects are not followed.
|
|
315
|
+
- **Receive.** `handleNoticeAction` verifies the token as php-sdk's `AuthenticateResource` does (RS256, `typ` at+jwt,
|
|
316
|
+
issuer from discovery, `aud`, `exp`, 30 s leeway) — not with `verifyAccessToken`, because Platform mints this token
|
|
317
|
+
without `organization_id`. It admits only a person (`prn` user) from `platform-notice-actions` with
|
|
318
|
+
`notices.actions:execute` (else `403 {"error":"forbidden"}`), reads the body (malformed: `422 {"state":"invalid"}`), and
|
|
319
|
+
answers `{state, message?}`: 200, or 422 for `invalid`. `done` / `already_done` settle the notice on Platform for good —
|
|
320
|
+
answer `already_done` only when the effect is confirmed, and never treat an unknown action as one you know.
|
|
321
|
+
- **One contract.** `contract/notices/` is byte-identical to php-sdk's `tests/Fixtures/notices/`; both suites run every
|
|
322
|
+
case and pin the same digest of the set. Change both in the same release.
|
|
323
|
+
|
|
239
324
|
## `./sync` — the identity feed
|
|
240
325
|
|
|
241
326
|
The platform publishes identity/directory changes (organizations, branches, brands, users,
|
|
@@ -618,6 +703,8 @@ npx vitest run # from packages/server-sdk
|
|
|
618
703
|
- `org.test.ts` (9): Bearer + Accept headers, Idempotency-Key (generated and caller-supplied),
|
|
619
704
|
pagination to the last page, 403 `WWW-Authenticate` surfaced, 401 audience hint, 422 per-field
|
|
620
705
|
validation, 429 `Retry-After`, notifications.create mapping + Idempotency-Key.
|
|
706
|
+
- `notifications.test.ts`: every shared notice fixture (valid and invalid bodies, Platform's answers, signed presses),
|
|
707
|
+
the typed builder, redirect/network/not-configured handling.
|
|
621
708
|
- `producer.test.ts` (8): subject/type composition, token charset refusal naming the offender,
|
|
622
709
|
sequence monotonicity, drain order + envelope shape (no tenantid), stop-on-first-failure,
|
|
623
710
|
published_at only after publish resolves, batch limit, DDL ships the drain index.
|
package/dist/auth.d.ts
CHANGED
|
@@ -62,12 +62,26 @@ interface EmbeddedAuthorization {
|
|
|
62
62
|
*/
|
|
63
63
|
declare function verifyAccessToken(token: string, opts: {
|
|
64
64
|
issuer: string;
|
|
65
|
-
|
|
66
|
-
|
|
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. */
|
|
67
68
|
audience?: string;
|
|
68
69
|
/** Headers, deadline and fetch for discovery and JWKS (first call per issuer decides the JWKS options). */
|
|
69
70
|
http?: HttpOptions;
|
|
70
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
|
+
}>;
|
|
71
85
|
interface Pkce {
|
|
72
86
|
verifier: string;
|
|
73
87
|
challenge: string;
|
|
@@ -80,4 +94,4 @@ declare function sealData(payload: Record<string, unknown>, secret: string, ttlS
|
|
|
80
94
|
/** Returns null for anything invalid or expired — never throws on bad input. */
|
|
81
95
|
declare function openData<T = Record<string, unknown>>(token: string, secret: string): Promise<T | null>;
|
|
82
96
|
|
|
83
|
-
export { type EmbeddedAuthorization, 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.js
CHANGED
|
@@ -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 {
|
|
@@ -46,6 +47,22 @@ async function verifyAccessToken(token, opts) {
|
|
|
46
47
|
expiresAt: payload.exp
|
|
47
48
|
});
|
|
48
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
|
+
}
|
|
49
66
|
async function createPkce() {
|
|
50
67
|
const verifier = randomToken(32);
|
|
51
68
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
@@ -108,9 +125,10 @@ async function openData(token, secret) {
|
|
|
108
125
|
|
|
109
126
|
export {
|
|
110
127
|
verifyAccessToken,
|
|
128
|
+
verifyIdToken,
|
|
111
129
|
createPkce,
|
|
112
130
|
randomToken,
|
|
113
131
|
sealData,
|
|
114
132
|
openData
|
|
115
133
|
};
|
|
116
|
-
//# 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":[]}
|