@konneal/engine 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ // The session's lifeline (TODO.identity-rp/01): a session's claims are
2
+ // frozen at mint — a deactivated account, a revoked role, a new avatar
3
+ // would all surface weeks late. So renewal is not a re-stamp of the old
4
+ // claims: when a session ages past the threshold, the RP presents the
5
+ // OP's refresh token, and the OP's refresh grant re-judges the LIVE
6
+ // standing (deactivation kills the grant family → the next renewal ends
7
+ // the session, and the response carries today's name/picture/roles).
8
+ // The rotated refresh token must be stored after every use — the OP's
9
+ // rotation is one-time, and a reuse verdict revokes the whole family.
10
+
11
+ import { discoverIssuer, validateIdToken, type OidcIdTokenClaims } from "./oidc.ts";
12
+ import { renewedSessionClaims, refreshGrant, type OidcMetadata } from "./refresh-core.ts";
13
+ import type { SessionClaims } from "./session";
14
+ import { readRefreshToken, dropRefreshToken, retainRefreshToken } from "./livedata";
15
+
16
+
17
+
18
+ export interface RenewedSession {
19
+ claims: Omit<SessionClaims, "iat" | "exp">;
20
+ refreshToken: string;
21
+ }
22
+
23
+ export type RenewOutcome =
24
+ | { kind: "ok"; renewed: RenewedSession }
25
+ | { kind: "revoked" }
26
+ | { kind: "unavailable" };
27
+
28
+ export async function renewSessionClaims(
29
+ env: any,
30
+ cfg: { issuer: string; clientId: string; clientSecret?: string; sessionSecret: string },
31
+ session: SessionClaims,
32
+ sessionRaw: string,
33
+ ): Promise<RenewOutcome> {
34
+ const stored = await readRefreshToken(env, sessionRaw);
35
+ if (!stored) return { kind: "unavailable" };
36
+ let meta;
37
+ try {
38
+ meta = (await discoverIssuer(cfg.issuer)) as unknown as OidcMetadata;
39
+ } catch {
40
+ return { kind: "unavailable" };
41
+ }
42
+ const granted = await refreshGrant(meta, cfg, stored);
43
+ // the OP's refusal IS the revocation signal (deactivated, signed out
44
+ // everywhere, expired) — the renewal ends; unavailability keeps the
45
+ // session for the next load
46
+ if (granted.kind === "refused") {
47
+ await dropRefreshToken(env, sessionRaw);
48
+ return { kind: "revoked" };
49
+ }
50
+ if (granted.kind === "unavailable") return granted;
51
+ const token = granted.token;
52
+ if (typeof token.refresh_token !== "string" || !token.refresh_token) {
53
+ // a rotation without a successor: the OP is telling us the grant is
54
+ // finished (narrowed away) — treat as revoked
55
+ await dropRefreshToken(env, sessionRaw);
56
+ return { kind: "revoked" };
57
+ }
58
+ let claims: OidcIdTokenClaims;
59
+ try {
60
+ claims = await validateIdToken(token.id_token, {
61
+ issuer: cfg.issuer,
62
+ clientId: cfg.clientId,
63
+ nonce: undefined as unknown as string,
64
+ jwksUri: meta.jwks_uri,
65
+ });
66
+ } catch {
67
+ return { kind: "unavailable" };
68
+ }
69
+ const fresh = renewedSessionClaims(session, claims);
70
+ if (!fresh) {
71
+ await dropRefreshToken(env, sessionRaw);
72
+ return { kind: "revoked" };
73
+ }
74
+ await retainRefreshToken(env, sessionRaw, token.refresh_token);
75
+ return { kind: "ok", renewed: { claims: fresh, refreshToken: token.refresh_token } };
76
+ }
@@ -152,6 +152,7 @@ export function buildAuthorizationUrl(
152
152
  export interface OidcTokenResponse {
153
153
  id_token: string;
154
154
  access_token?: string;
155
+ refresh_token?: string;
155
156
  token_type?: string;
156
157
  expires_in?: number;
157
158
  }
@@ -0,0 +1,67 @@
1
+ // The dependency-free core of the session renewal (node-loadable for
2
+ // unit tests — the law that produced verdict-parse.ts): the refresh
3
+ // grant's backchannel classification and the claims merge with its
4
+ // sub guard. The stateful orchestration lives in oidc-refresh.ts.
5
+ import type { OidcIdTokenClaims, OidcTokenResponse } from "./oidc.ts";
6
+ import type { SessionClaims } from "./session";
7
+
8
+ export interface OidcMetadata {
9
+ token_endpoint: string;
10
+ jwks_uri: string;
11
+ }
12
+
13
+ export type RefreshResult =
14
+ | { kind: "ok"; token: OidcTokenResponse }
15
+ | { kind: "refused" }
16
+ | { kind: "unavailable" };
17
+
18
+ /** The refresh grant's backchannel: the same shape as the code
19
+ * exchange's POST (the OP's WAF wants the issuer's own origin), with
20
+ * the three outcomes the renewal policy needs — a 400 is the OP
21
+ * REFUSING (revocation, expiry: dead), a network throw is
22
+ * unavailability (keep the session, retry next load). */
23
+ export async function refreshGrant(
24
+ meta: OidcMetadata,
25
+ cfg: { clientId: string; clientSecret?: string },
26
+ refreshToken: string,
27
+ ): Promise<RefreshResult> {
28
+ const body = new URLSearchParams({
29
+ grant_type: "refresh_token",
30
+ refresh_token: refreshToken,
31
+ client_id: cfg.clientId,
32
+ });
33
+ const headers: Record<string, string> = { "content-type": "application/x-www-form-urlencoded" };
34
+ headers.origin = new URL(meta.token_endpoint).origin;
35
+ if (cfg.clientSecret) {
36
+ headers.authorization = `Basic ${btoa(`${encodeURIComponent(cfg.clientId)}:${encodeURIComponent(cfg.clientSecret)}`)}`;
37
+ }
38
+ let json: unknown;
39
+ try {
40
+ const res = await fetch(meta.token_endpoint, { method: "POST", headers, body });
41
+ json = await res.json().catch(() => ({}));
42
+ if (!res.ok) return { kind: "refused" };
43
+ } catch {
44
+ return { kind: "unavailable" };
45
+ }
46
+ const token = json as Partial<OidcTokenResponse>;
47
+ if (typeof token?.id_token !== "string") return { kind: "unavailable" };
48
+ return { kind: "ok", token: token as OidcTokenResponse };
49
+ }
50
+
51
+ /** The fresh session claims from the refresh grant's ID token. The sub
52
+ * MUST match (a different account is a provisioning error, never an
53
+ * update); every profile field falls back to the standing session's so
54
+ * a claim the OP omits never erases what the user already had. */
55
+ export function renewedSessionClaims(
56
+ session: SessionClaims,
57
+ claims: OidcIdTokenClaims,
58
+ ): Omit<SessionClaims, "iat" | "exp"> | null {
59
+ if (claims.sub !== session.sub) return null;
60
+ return {
61
+ sub: session.sub,
62
+ name: typeof claims.name === "string" && claims.name ? claims.name : session.name,
63
+ email: typeof claims.email === "string" && claims.email ? claims.email : session.email,
64
+ picture: typeof claims.picture === "string" && claims.picture ? claims.picture : session.picture,
65
+ roles: Array.isArray(claims.roles) ? claims.roles.map(String) : session.roles,
66
+ };
67
+ }
@@ -1,12 +0,0 @@
1
- import {
2
- handleAsk
3
- } from "./chunk-EE6V6X6D.js";
4
- import "./chunk-DGFJUA2C.js";
5
- import "./chunk-BV7IKH3N.js";
6
- import "./chunk-FNJ457SE.js";
7
- import "./chunk-COKARI7O.js";
8
- import "./chunk-RESCBSX6.js";
9
- import "./chunk-YTWPZE5O.js";
10
- export {
11
- handleAsk
12
- };