@lpshanley/oidc-start-core 0.1.0-next.1 → 0.2.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 +21 -0
- package/README.md +11 -3
- package/dist/config.d.ts +4 -1
- package/dist/core.d.ts +1 -0
- package/dist/core.js +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +10 -4
- package/dist/refresh.d.ts +30 -0
- package/dist/refresh.js +35 -0
- package/dist/session.d.ts +5 -8
- package/dist/session.js +31 -13
- package/package.json +2 -1
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
|
@@ -4,12 +4,14 @@ OpenID Connect sign-in for Node.js servers. Give the core a standard `Request` a
|
|
|
4
4
|
|
|
5
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
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
|
+
|
|
7
9
|
## Install and register your app
|
|
8
10
|
|
|
9
|
-
Use Node.js 22.12 or newer with ES modules. Install the
|
|
11
|
+
Use Node.js 22.12 or newer with ES modules. Install the package from npm.
|
|
10
12
|
|
|
11
13
|
```sh
|
|
12
|
-
npm install @lpshanley/oidc-start-core
|
|
14
|
+
npm install @lpshanley/oidc-start-core
|
|
13
15
|
```
|
|
14
16
|
|
|
15
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.
|
|
@@ -21,7 +23,7 @@ Register a **confidential web client** with your OIDC provider. For the example
|
|
|
21
23
|
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | The credentials issued to your web client. |
|
|
22
24
|
| `SESSION_SECRET` | Generate once with `openssl rand -hex 32`. Keep the same secret across restarts and server instances. |
|
|
23
25
|
| `OIDC_SCOPES` | Optional. Defaults to `openid profile email offline_access`. The provider must supply an email in the ID token or through UserInfo. |
|
|
24
|
-
| `OIDC_ROLES_CLAIM` | Optional. Defaults to `roles`.
|
|
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. |
|
|
25
27
|
|
|
26
28
|
## Create the core
|
|
27
29
|
|
|
@@ -52,6 +54,7 @@ Mount these handlers at `GET /sign-in` and `GET /callback` in your server. The f
|
|
|
52
54
|
|
|
53
55
|
```ts
|
|
54
56
|
import { SessionAccessDeniedError } from '@lpshanley/oidc-start-core'
|
|
57
|
+
import { isAuthorizationDenied } from '@lpshanley/oidc-start-core/provider'
|
|
55
58
|
import { auth } from './auth.server.js'
|
|
56
59
|
|
|
57
60
|
function authHeaders(cookies: string[]) {
|
|
@@ -73,6 +76,9 @@ export async function callback(request: Request) {
|
|
|
73
76
|
const result = await auth.callback(request, cookies)
|
|
74
77
|
const headers = authHeaders([...setCookies, ...result.setCookies])
|
|
75
78
|
if (!result.ok) {
|
|
79
|
+
if (isAuthorizationDenied(result.error)) {
|
|
80
|
+
return new Response('Authorization was declined.', { status: 403, headers })
|
|
81
|
+
}
|
|
76
82
|
const denied = result.error instanceof SessionAccessDeniedError
|
|
77
83
|
return new Response(denied ? 'Access denied.' : 'Please sign in again.', {
|
|
78
84
|
status: denied ? 403 : 400, headers,
|
|
@@ -85,6 +91,8 @@ export async function callback(request: Request) {
|
|
|
85
91
|
|
|
86
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`.
|
|
87
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
|
+
|
|
88
96
|
## End a session
|
|
89
97
|
|
|
90
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.
|
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
|
|
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";
|
package/dist/provider.d.ts
CHANGED
|
@@ -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>;
|
package/dist/refresh.js
ADDED
|
@@ -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>,
|
|
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
|
-
|
|
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,
|
|
103
|
-
const checked = await readActive(cookies,
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
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 };
|