@lpshanley/oidc-start-core 0.1.0-next.0 → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucas Shanley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,25 +1,117 @@
1
1
  # @lpshanley/oidc-start-core
2
2
 
3
- OpenID Connect for a Node.js server. Handles discovery, code exchange with PKCE, sealed browser sessions, refresh, revocation and back-channel logout. Use the separate `@lpshanley/oidc-start-react` package for TanStack Start.
3
+ OpenID Connect sign-in for Node.js servers. Give the core a standard `Request` and it handles provider discovery, the authorization code exchange with PKCE, and sealed session cookies. Your app decides who can sign in and where to record revoked sessions.
4
+
5
+ Building a TanStack Start app? The React adapter connects the core to Start’s request middleware, routes, and server functions. [TanStack Start quickstart](https://www.npmjs.com/package/@lpshanley/oidc-start-react)
6
+
7
+ For multi-replica deployment, see the [production guide](https://github.com/lpshanley/oidc-start/blob/main/docs/production.md) and [tested PostgreSQL example](https://github.com/lpshanley/oidc-start/tree/main/examples/postgres).
8
+
9
+ ## Install and register your app
10
+
11
+ Use Node.js 22.12 or newer with ES modules. Install the package from npm.
12
+
13
+ ```sh
14
+ npm install @lpshanley/oidc-start-core
15
+ ```
16
+
17
+ Register a **confidential web client** with your OIDC provider. For the example below, register `http://localhost:5173/callback` as the sign-in redirect URI and `http://localhost:5173/` as the post-logout redirect URI. In production, use your app’s public HTTPS origin for both.
18
+
19
+ | Server setting | What to supply |
20
+ | --- | --- |
21
+ | `APP_ORIGIN` | The public origin of your app, such as `http://localhost:5173` in development. |
22
+ | `OIDC_ISSUER` | The provider’s issuer URL, including its tenant path if it has one. |
23
+ | `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | The credentials issued to your web client. |
24
+ | `SESSION_SECRET` | Generate once with `openssl rand -hex 32`. Keep the same secret across restarts and server instances. |
25
+ | `OIDC_SCOPES` | Optional. Defaults to `openid profile email offline_access`. The provider must supply an email in the ID token or through UserInfo. |
26
+ | `OIDC_ROLES_CLAIM` | Optional. Defaults to `roles`. If the ID token omits this claim, the core checks UserInfo when the provider advertises it. Without either source, your policy receives an empty role list. |
27
+
28
+ ## Create the core
29
+
30
+ Create one instance in a server module. This example requires the `members` role, so give your test user that role at the provider. The core checks the policy at sign-in, when reading a session, and after refreshing it.
4
31
 
5
32
  ```ts
33
+ // auth.server.ts
6
34
  import { createOidcCore } from '@lpshanley/oidc-start-core'
7
35
  import { hasRole } from '@lpshanley/oidc-start-core/models'
36
+ import { revocations } from './revocations.js'
8
37
 
9
- const auth = createOidcCore({
38
+ export const auth = createOidcCore({
10
39
  cookiePrefix: 'myapp',
11
- callbackPath: '/auth/callback',
12
- postLogoutPath: '/signed-out',
40
+ callbackPath: '/callback',
41
+ postLogoutPath: '/',
13
42
  allowSession: user => hasRole(user, 'members'),
14
- revocations: mySessionRevocationStore,
43
+ revocations,
15
44
  })
45
+
16
46
  auth.assertConfigured()
17
47
  ```
18
48
 
19
- Provide `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and a `SESSION_SECRET` of at least 32 characters. Set `APP_ORIGIN` to the public app origin and `OIDC_ROLES_CLAIM` to the provider's role claim (default `roles`). Register the callback and post-logout URLs. Each app chooses its own role policy and supplies a `SessionRevocations` implementation shared by its server instances. The core checks that policy at sign-in, on session access and after refresh.
49
+ `./revocations.js` is an app module you supply. For a local trial, copy the three maps and the `revocations` object from the example into `revocations.ts` and export the object. A deployed app needs durable storage shared by every instance. [Local revocation store](https://github.com/lpshanley/oidc-start/blob/main/examples/tanstack-start/src/auth.server.ts)
50
+
51
+ ## Wire sign-in and callback
52
+
53
+ Mount these handlers at `GET /sign-in` and `GET /callback` in your server. The first sends the browser to the provider. The second checks the code and browser verifier, sets the session cookie, and returns the user to `/`.
54
+
55
+ ```ts
56
+ import { SessionAccessDeniedError } from '@lpshanley/oidc-start-core'
57
+ import { isAuthorizationDenied } from '@lpshanley/oidc-start-core/provider'
58
+ import { auth } from './auth.server.js'
59
+
60
+ function authHeaders(cookies: string[]) {
61
+ const headers = new Headers({ 'Cache-Control': 'no-store' })
62
+ for (const cookie of cookies) headers.append('Set-Cookie', cookie)
63
+ return headers
64
+ }
65
+
66
+ export async function signIn(request: Request) {
67
+ const { cookies, setCookies } = await auth.resolve(request)
68
+ const flow = await auth.signIn(request, cookies, '/')
69
+ const headers = authHeaders([...setCookies, ...flow.setCookies])
70
+ headers.set('Location', flow.url)
71
+ return new Response(null, { status: 302, headers })
72
+ }
73
+
74
+ export async function callback(request: Request) {
75
+ const { cookies, setCookies } = await auth.resolve(request)
76
+ const result = await auth.callback(request, cookies)
77
+ const headers = authHeaders([...setCookies, ...result.setCookies])
78
+ if (!result.ok) {
79
+ if (isAuthorizationDenied(result.error)) {
80
+ return new Response('Authorization was declined.', { status: 403, headers })
81
+ }
82
+ const denied = result.error instanceof SessionAccessDeniedError
83
+ return new Response(denied ? 'Access denied.' : 'Please sign in again.', {
84
+ status: denied ? 403 : 400, headers,
85
+ })
86
+ }
87
+ headers.set('Location', result.location)
88
+ return new Response(null, { status: 302, headers })
89
+ }
90
+ ```
91
+
92
+ On an ordinary request, `const { session, setCookies } = await auth.resolve(request)` gives you `session?.user`. Forward **each** returned `Set-Cookie` header separately, even on a signed-out response. A read can refresh a session or clear an expired cookie. Keep the full session on the server because it contains provider tokens. Browser code can use `AuthUser`, `displayName`, and `hasRole` from `@lpshanley/oidc-start-core/models`.
93
+
94
+ If you use `provider.exchangeCode()` directly, import `isAuthorizationDenied` from `@lpshanley/oidc-start-core/provider` and pass it the caught error. It recognizes the provider’s `access_denied` response across bundles. Other responses, such as `login_required`, and network failures return `false`.
95
+
96
+ ## End a session
97
+
98
+ For browser sign-out, use a same-origin, CSRF-protected POST handler. Resolve the request, pass its `cookies` and `session` to `auth.signOut(request, cookies, session)`, append both sets of cookies as above, and redirect with **303** to the returned `location`. If recording the revocation throws, return a retryable error. The default session lasts up to 14 days and expires after 12 hours of inactivity.
99
+
100
+ Implement these four methods on your `SessionRevocations` store. All times are in epoch seconds. Let storage errors propagate so the core can distinguish an outage from an active session.
101
+
102
+ | Method | Storage rule |
103
+ | --- | --- |
104
+ | `revoke(sid, until)` | Mark this app session as revoked through its expiry. |
105
+ | `revokeIdpSession(idpSid, until)` | Mark the provider session as revoked. Atomically keep the later expiry. |
106
+ | `revokeAllFor(sub, cutoff)` | Atomically keep the greatest cutoff for the subject. |
107
+ | `isRevoked({ sid, idpSid, sub, issuedAt })` | Return true for a live revocation of either session ID, or when `issuedAt <= cutoff` for this subject. |
108
+
109
+ For provider-initiated logout, register a POST endpoint and pass its request to `auth.backchannelLogout(request)`. Return the resulting HTTP status to the provider. The core verifies the signed logout token, including its expiry and nonempty token ID. A redelivered event uses the original issue time. [Revocation interface](https://github.com/lpshanley/oidc-start/blob/main/packages/core/src/revocation.ts)
20
110
 
21
- Back-channel logout requires a signed token with an expiration (`exp`) and a nonempty token ID (`jti`). The signed issue time sets the revocation cutoff, in epoch seconds. For `revokeAllFor` and `revokeIdpSession`, atomically keep the greater of the stored and incoming timestamp so an older delivery cannot undo a newer revocation. A subject cutoff includes sessions issued in the same second.
111
+ ## Obtain a separate provider grant
22
112
 
23
- Use `startGrant` and `finishGrant` on the server to obtain an independent provider grant. Register a separate `grantCallbackPath` (default `/api/auth/grant-callback`). The sealed state binds each flow to its purpose. The host checks the returned subject and permissions, stores the credentials and decides which client can use them. `refreshGrant` returns `refused` for a provider rejection of the grant. Other errors propagate. A `RefreshNotDispatched` from `/provider` means the refresh request was never sent.
113
+ Use this flow when your app needs credentials for provider API calls independently of the browser session. Register a second redirect URI with the provider. The default path is `/api/auth/grant-callback`, configurable with `grantCallbackPath`.
24
114
 
25
- The `/models` entry is safe to import in browser code. Credentials and session management belong on the server.
115
+ 1. Call `auth.startGrant(request, cookies, returnPathname)`, send its cookies, and redirect to its `url`.
116
+ 2. In the grant callback, call `auth.finishGrant(request, cookies)` and forward its cookies on success or failure. On success, check the returned user and permissions before storing the tokens on the server.
117
+ 3. Call `auth.refreshGrant(refreshToken)` to renew the grant. A `refused` result means the provider rejected it. Other errors propagate. `RefreshNotDispatched` from `@lpshanley/oidc-start-core/provider` means the refresh request was never sent.
package/dist/config.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { AuthUser } from "./models.js";
3
+ import type { SessionRefreshCoordinator } from "./refresh.js";
3
4
  import type { SessionRevocations } from "./revocation.js";
4
5
  export type AuthEnvironment = Readonly<Record<string, string | undefined>>;
5
6
  export type AuthEnvironmentProvider = () => AuthEnvironment;
@@ -41,7 +42,7 @@ export type OidcCallContext = {
41
42
  };
42
43
  /** A failure the library recovered from or reported to the browser. */
43
44
  export type OidcErrorEvent = {
44
- operation: "sign-in" | "callback" | "refresh" | "end-session" | "token-revocation" | "backchannel-logout";
45
+ operation: "sign-in" | "callback" | "refresh" | "end-session" | "revocation-read" | "token-revocation" | "backchannel-logout";
45
46
  error: unknown;
46
47
  context?: OidcCallContext;
47
48
  };
@@ -50,6 +51,8 @@ export type OidcMiddlewareOptions = AuthServiceOptions & {
50
51
  allowSession: (user: AuthUser) => boolean;
51
52
  /** Server-side record of ended sessions. */
52
53
  revocations: SessionRevocations;
54
+ /** Share rotating session refreshes between app instances. */
55
+ refreshCoordinator?: SessionRefreshCoordinator;
53
56
  /** Read configuration inside each request. Defaults to process.env. */
54
57
  environment?: AuthEnvironmentProvider;
55
58
  /**
package/dist/core.d.ts CHANGED
@@ -64,6 +64,7 @@ export declare function createOidcCore(options: OidcMiddlewareOptions): {
64
64
  issuedAt: number;
65
65
  lastSeenAt: number;
66
66
  refreshToken?: string | undefined;
67
+ refreshGeneration?: number | undefined;
67
68
  idToken?: string | undefined;
68
69
  idpSid?: string | undefined;
69
70
  } | null;
package/dist/core.js CHANGED
@@ -137,7 +137,7 @@ export function createOidcCore(options) {
137
137
  ? [expire(verifierCookieName(prefix(), state))]
138
138
  : [];
139
139
  try {
140
- if (!state || !url.searchParams.get("code")) {
140
+ if (!state || !(url.searchParams.get("code") || url.searchParams.get("error"))) {
141
141
  throw new OidcStateError("missing");
142
142
  }
143
143
  const flow = await verifyCallbackState({
@@ -169,13 +169,14 @@ export function createOidcCore(options) {
169
169
  password,
170
170
  provider,
171
171
  revocations: options.revocations,
172
+ refreshCoordinator: options.refreshCoordinator,
172
173
  allowSession: options.allowSession,
173
174
  });
174
175
  return {
175
176
  /** Reads, and when needed refreshes or clears, the request's session. */
176
177
  async resolve(request, context) {
177
178
  const cookies = parseCookies(request.headers.get("cookie"));
178
- const { session, setCookies } = await sessions.resolve(cookies, (error) => onError({ operation: "refresh", error, context }), (work) => withinDeadline(work, context), context);
179
+ const { session, setCookies } = await sessions.resolve(cookies, (error, operation) => onError({ operation, error, context }), (work) => withinDeadline(work, context), context);
179
180
  return { cookies, session, setCookies };
180
181
  },
181
182
  /** An authorization URL plus the flow's verifier cookie. */
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { createOidcCore, SessionAccessDeniedError, type OidcCore, type OidcAuth, type GrantRefresh, type GrantResult, } from "./core.js";
2
+ export type { SessionRefreshCoordinator } from "./refresh.js";
2
3
  export type { Session, SessionRevocations } from "./session.js";
3
4
  export type { OidcMiddlewareOptions, OidcCallContext } from "./config.js";
4
5
  export type { TokenSet } from "./provider.js";
@@ -17,6 +17,8 @@ export declare const PROVIDER_TIMEOUT_MS = 5000;
17
17
  /** The provider has not received a token refresh request. */
18
18
  export declare class RefreshNotDispatched extends Error {
19
19
  }
20
+ /** Whether the provider returned access_denied in its authorization response. */
21
+ export declare function isAuthorizationDenied(error: unknown): boolean;
20
22
  export declare function createProvider(settings: OidcConfiguration, timeoutMs?: number): {
21
23
  authorizationUrl: (input: Parameters<({ redirectUri, state, nonce, codeVerifier, }: {
22
24
  redirectUri: string;
package/dist/provider.js CHANGED
@@ -58,6 +58,12 @@ export const PROVIDER_TIMEOUT_MS = 5_000;
58
58
  /** The provider has not received a token refresh request. */
59
59
  export class RefreshNotDispatched extends Error {
60
60
  }
61
+ /** Whether the provider returned access_denied in its authorization response. */
62
+ export function isAuthorizationDenied(error) {
63
+ return typeof error === "object" && error !== null
64
+ && "code" in error && error.code === "OAUTH_AUTHORIZATION_RESPONSE_ERROR"
65
+ && "error" in error && error.error === "access_denied";
66
+ }
61
67
  export function createProvider(settings, timeoutMs = PROVIDER_TIMEOUT_MS) {
62
68
  // The SDK caches discovery and JWKS clients and accepts a single transport hook.
63
69
  // Only this transport adapter uses async-local state; domain callers pass context explicitly.
@@ -154,7 +160,7 @@ export function createProvider(settings, timeoutMs = PROVIDER_TIMEOUT_MS) {
154
160
  let email = firstString(claims.email);
155
161
  const tokenRoles = roleList(claims[settings.OIDC_ROLES_CLAIM]);
156
162
  let info;
157
- if (!email || tokenRoles === undefined) {
163
+ if ((!email || tokenRoles === undefined) && config.serverMetadata().userinfo_endpoint) {
158
164
  info = await client.fetchUserInfo(config, response.access_token, claims.sub);
159
165
  email = email ?? firstString(info.email);
160
166
  }
@@ -182,11 +188,11 @@ export function createProvider(settings, timeoutMs = PROVIDER_TIMEOUT_MS) {
182
188
  error: "invalid_grant",
183
189
  });
184
190
  let roles = roleList(claims?.[settings.OIDC_ROLES_CLAIM]);
185
- if (roles === undefined) {
191
+ if (roles === undefined && config.serverMetadata().userinfo_endpoint) {
186
192
  const info = await client.fetchUserInfo(config, response.access_token, sub);
187
- roles = roleList(info[settings.OIDC_ROLES_CLAIM]) ?? [];
193
+ roles = roleList(info[settings.OIDC_ROLES_CLAIM]);
188
194
  }
189
- return { ...tokenSet(response, refreshToken), roles };
195
+ return { ...tokenSet(response, refreshToken), roles: roles ?? [] };
190
196
  },
191
197
  /**
192
198
  * Verifies a back-channel logout token's signature against the provider's
@@ -0,0 +1,30 @@
1
+ import type { RefreshedTokens } from "./provider.js";
2
+ import type { Session } from "./session.js";
3
+ /** Reuse a recent refresh when a concurrent request still has the old cookie. */
4
+ export declare const RECENT_REFRESH_MS = 10000;
5
+ /** Coordinate a session refresh across replicas of one app. Times named "until" are epoch seconds. */
6
+ export interface SessionRefreshCoordinator {
7
+ /**
8
+ * Atomically reserve the key before calling produce. Keep the reservation through
9
+ * until, even if the outcome is unknown. Concurrent callers may reuse the sealed
10
+ * result for reuseMs after completion. Return null for a spent key or wait timeout.
11
+ */
12
+ run(request: {
13
+ key: string;
14
+ until: number;
15
+ reuseMs: number;
16
+ }, produce: () => Promise<string>): Promise<string | null>;
17
+ }
18
+ export declare function sharedRefresh(coordinator: SessionRefreshCoordinator, { session, until, password, refresh }: {
19
+ session: Session;
20
+ until: number;
21
+ password: string;
22
+ refresh: () => Promise<RefreshedTokens>;
23
+ }): Promise<{
24
+ accessToken: string;
25
+ expiresAt: number;
26
+ roles: string[];
27
+ refreshToken?: string | undefined;
28
+ idToken?: string | undefined;
29
+ refreshExpiresAt?: number | undefined;
30
+ } | null>;
@@ -0,0 +1,35 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { sealValue, unsealValue } from "./seal.js";
4
+ /** Reuse a recent refresh when a concurrent request still has the old cookie. */
5
+ export const RECENT_REFRESH_MS = 10_000;
6
+ const refreshResult = z.object({
7
+ key: z.string(),
8
+ useBefore: z.number(),
9
+ tokens: z.object({
10
+ accessToken: z.string().min(1),
11
+ refreshToken: z.string().min(1).optional(),
12
+ idToken: z.string().min(1).optional(),
13
+ expiresAt: z.number(),
14
+ refreshExpiresAt: z.number().optional(),
15
+ roles: z.array(z.string()),
16
+ }),
17
+ });
18
+ export async function sharedRefresh(coordinator, { session, until, password, refresh }) {
19
+ // Some providers return the same refresh token on successive renewals.
20
+ // Each generation gets its own reservation within this sign-in.
21
+ const key = createHash("sha256").update(JSON.stringify([
22
+ session.sid, session.user.sub, session.refreshGeneration ?? 0, session.refreshToken,
23
+ ])).digest("hex");
24
+ const ttl = RECENT_REFRESH_MS / 1000;
25
+ const sealed = await coordinator.run({ key, until, reuseMs: RECENT_REFRESH_MS }, async () => {
26
+ const tokens = await refresh();
27
+ return sealValue({ key, tokens, useBefore: Date.now() + RECENT_REFRESH_MS }, password, ttl);
28
+ });
29
+ if (sealed === null)
30
+ return null;
31
+ const result = refreshResult.parse(await unsealValue(sealed, password, ttl));
32
+ if (result.key !== key)
33
+ throw new Error("Mismatched session refresh result");
34
+ return Date.now() < result.useBefore ? result.tokens : null;
35
+ }
package/dist/session.d.ts CHANGED
@@ -2,16 +2,11 @@ import type { OidcCallContext } from "./config.js";
2
2
  import { z } from "zod";
3
3
  import { type CookieOptions } from "./cookies.js";
4
4
  import type { OidcProvider } from "./provider.js";
5
+ import { type SessionRefreshCoordinator } from "./refresh.js";
5
6
  import type { SessionRevocations } from "./revocation.js";
6
7
  export type { SessionRevocations };
7
8
  /** Refresh this many seconds before the access token expires. */
8
9
  export declare const REFRESH_LEEWAY_SECONDS = 30;
9
- /**
10
- * How long a completed refresh is reused for requests still carrying the old
11
- * refresh token, so a provider that rotates refresh tokens doesn't reject the
12
- * stale one from a parallel request or another tab.
13
- */
14
- export declare const RECENT_REFRESH_MS = 10000;
15
10
  declare const sessionSchema: z.ZodObject<{
16
11
  user: z.ZodObject<{
17
12
  sub: z.ZodString;
@@ -22,6 +17,7 @@ declare const sessionSchema: z.ZodObject<{
22
17
  }, z.core.$strip>;
23
18
  accessToken: z.ZodString;
24
19
  refreshToken: z.ZodOptional<z.ZodString>;
20
+ refreshGeneration: z.ZodOptional<z.ZodNumber>;
25
21
  idToken: z.ZodOptional<z.ZodString>;
26
22
  expiresAt: z.ZodNumber;
27
23
  sid: z.ZodString;
@@ -37,7 +33,7 @@ export type SessionResolution = {
37
33
  /** Set-Cookie lines to apply: a refreshed session, or a cleared one. */
38
34
  setCookies: string[];
39
35
  };
40
- export declare function createSessionManager({ cookieName: name, cookieOptions, maxAgeSeconds, idleSeconds, password, provider, revocations, allowSession, }: {
36
+ export declare function createSessionManager({ cookieName: name, cookieOptions, maxAgeSeconds, idleSeconds, password, provider, revocations, refreshCoordinator, allowSession, }: {
41
37
  cookieName: () => string;
42
38
  cookieOptions: () => CookieOptions;
43
39
  /** Absolute lifetime from sign-in. */
@@ -47,11 +43,12 @@ export declare function createSessionManager({ cookieName: name, cookieOptions,
47
43
  password: () => string;
48
44
  provider: () => OidcProvider;
49
45
  revocations: SessionRevocations;
46
+ refreshCoordinator?: SessionRefreshCoordinator;
50
47
  /** Whether the (possibly refreshed) user may keep a session. */
51
48
  allowSession: (user: Session["user"]) => boolean;
52
49
  }): {
53
50
  readActive: (cookies: Map<string, string>, onStoreError?: (error: unknown) => void) => Promise<SessionResolution>;
54
- resolve: (cookies: Map<string, string>, onRefreshError?: (error: unknown) => void, bound?: <T>(work: Promise<T>) => Promise<T>, context?: OidcCallContext) => Promise<SessionResolution>;
51
+ resolve: (cookies: Map<string, string>, onError?: (error: unknown, operation: "refresh" | "revocation-read") => void, bound?: <T>(work: Promise<T>) => Promise<T>, context?: OidcCallContext) => Promise<SessionResolution>;
55
52
  write: (cookies: Map<string, string>, session: Session) => Promise<string[]>;
56
53
  clear: (cookies: Map<string, string>) => string[];
57
54
  };
package/dist/session.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { clearChunked, readChunked, writeChunked, } from "./cookies.js";
3
3
  import { authUserSchema } from "./models.js";
4
+ import { RECENT_REFRESH_MS, sharedRefresh } from "./refresh.js";
4
5
  import { sealValue, unsealValue } from "./seal.js";
5
6
  // Session resolution after WorkOS authkit-session's AuthKitCore and
6
7
  // AuthService: the sealed cookie holds the user and tokens, and an expiring
@@ -8,16 +9,12 @@ import { sealValue, unsealValue } from "./seal.js";
8
9
  // refreshes shortly before expiry and clears the cookie when refresh fails.
9
10
  /** Refresh this many seconds before the access token expires. */
10
11
  export const REFRESH_LEEWAY_SECONDS = 30;
11
- /**
12
- * How long a completed refresh is reused for requests still carrying the old
13
- * refresh token, so a provider that rotates refresh tokens doesn't reject the
14
- * stale one from a parallel request or another tab.
15
- */
16
- export const RECENT_REFRESH_MS = 10_000;
17
12
  const sessionSchema = z.object({
18
13
  user: authUserSchema,
19
14
  accessToken: z.string().min(1),
20
15
  refreshToken: z.string().min(1).optional(),
16
+ /** Sequence number of this session’s last coordinated token refresh. */
17
+ refreshGeneration: z.number().int().nonnegative().optional(),
21
18
  idToken: z.string().min(1).optional(),
22
19
  expiresAt: z.number(),
23
20
  /** Random id naming this sign-in, so it can be revoked server-side. */
@@ -31,7 +28,7 @@ const sessionSchema = z.object({
31
28
  });
32
29
  /** Re-seal the cookie once the last-seen time is this stale, to keep idle tracking current. */
33
30
  export const TOUCH_AFTER_SECONDS = 300;
34
- export function createSessionManager({ cookieName: name, cookieOptions, maxAgeSeconds, idleSeconds, password, provider, revocations, allowSession, }) {
31
+ export function createSessionManager({ cookieName: name, cookieOptions, maxAgeSeconds, idleSeconds, password, provider, revocations, refreshCoordinator, allowSession, }) {
35
32
  const refreshes = new Map();
36
33
  function refreshOnce(refreshToken, sub, context) {
37
34
  const now = Date.now();
@@ -87,7 +84,13 @@ export function createSessionManager({ cookieName: name, cookieOptions, maxAgeSe
87
84
  return { session: null, setCookies: clear(cookies) };
88
85
  }
89
86
  try {
90
- if (await revocations.isRevoked({ ...session, sub: session.user.sub })) {
87
+ const revoked = await revocations.isRevoked({
88
+ sid: session.sid,
89
+ sub: session.user.sub,
90
+ issuedAt: session.issuedAt,
91
+ ...(session.idpSid !== undefined && { idpSid: session.idpSid }),
92
+ });
93
+ if (revoked) {
91
94
  return { session: null, setCookies: clear(cookies) };
92
95
  }
93
96
  }
@@ -99,8 +102,8 @@ export function createSessionManager({ cookieName: name, cookieOptions, maxAgeSe
99
102
  }
100
103
  return { session, setCookies: [] };
101
104
  }
102
- async function resolve(cookies, onRefreshError = () => { }, bound = (work) => work, context) {
103
- const checked = await readActive(cookies, onRefreshError);
105
+ async function resolve(cookies, onError = () => { }, bound = (work) => work, context) {
106
+ const checked = await readActive(cookies, (error) => onError(error, "revocation-read"));
104
107
  const { session } = checked;
105
108
  if (!session)
106
109
  return checked;
@@ -116,10 +119,23 @@ export function createSessionManager({ cookieName: name, cookieOptions, maxAgeSe
116
119
  return { session: null, setCookies: clear(cookies) };
117
120
  }
118
121
  try {
119
- const { roles, ...tokens } = await bound(refreshOnce(session.refreshToken, session.user.sub, context));
122
+ const refreshToken = session.refreshToken;
123
+ const work = refreshCoordinator
124
+ ? sharedRefresh(refreshCoordinator, {
125
+ session,
126
+ until: session.issuedAt + maxAgeSeconds,
127
+ password: password(),
128
+ refresh: () => provider().refresh(refreshToken, session.user.sub, context),
129
+ })
130
+ : refreshOnce(refreshToken, session.user.sub, context);
131
+ const refreshedTokens = await bound(work);
132
+ if (!refreshedTokens)
133
+ return { session: null, setCookies: [] };
134
+ const { roles, ...tokens } = refreshedTokens;
120
135
  const refreshed = {
121
136
  ...session,
122
137
  ...tokens,
138
+ ...(refreshCoordinator && { refreshGeneration: (session.refreshGeneration ?? 0) + 1 }),
123
139
  idToken: tokens.idToken ?? session.idToken,
124
140
  user: { ...session.user, roles },
125
141
  lastSeenAt: now,
@@ -133,8 +149,10 @@ export function createSessionManager({ cookieName: name, cookieOptions, maxAgeSe
133
149
  };
134
150
  }
135
151
  catch (error) {
136
- onRefreshError(error);
137
- return { session: null, setCookies: clear(cookies) };
152
+ onError(error, "refresh");
153
+ // A peer may already have written the renewed cookie, even if this
154
+ // caller timed out. A failed coordinated refresh must never erase it.
155
+ return { session: null, setCookies: refreshCoordinator ? [] : clear(cookies) };
138
156
  }
139
157
  }
140
158
  return { readActive, resolve, write, clear };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@lpshanley/oidc-start-core",
3
- "version": "0.1.0-next.0",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
4
5
  "repository": {
5
6
  "type": "git",
6
7
  "url": "git+https://github.com/lpshanley/oidc-start.git",