@lpshanley/oidc-start-core 0.0.0-stage → 0.1.0-next.1
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 +108 -2
- package/dist/config.d.ts +61 -0
- package/dist/config.js +71 -0
- package/dist/cookies.d.ts +21 -0
- package/dist/cookies.js +93 -0
- package/dist/core.d.ts +123 -0
- package/dist/core.js +352 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1 -0
- package/dist/logout-token.d.ts +16 -0
- package/dist/logout-token.js +36 -0
- package/dist/models.d.ts +29 -0
- package/dist/models.js +57 -0
- package/dist/provider.d.ts +49 -0
- package/dist/provider.js +242 -0
- package/dist/revocation.d.ts +19 -0
- package/dist/revocation.js +1 -0
- package/dist/seal.d.ts +4 -0
- package/dist/seal.js +36 -0
- package/dist/session.d.ts +58 -0
- package/dist/session.js +141 -0
- package/dist/state.d.ts +50 -0
- package/dist/state.js +84 -0
- package/package.json +56 -3
package/README.md
CHANGED
|
@@ -1,3 +1,109 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lpshanley/oidc-start-core
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
## Install and register your app
|
|
8
|
+
|
|
9
|
+
Use Node.js 22.12 or newer with ES modules. Install the current prerelease from npm.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @lpshanley/oidc-start-core@next
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
| Server setting | What to supply |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `APP_ORIGIN` | The public origin of your app, such as `http://localhost:5173` in development. |
|
|
20
|
+
| `OIDC_ISSUER` | The provider’s issuer URL, including its tenant path if it has one. |
|
|
21
|
+
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | The credentials issued to your web client. |
|
|
22
|
+
| `SESSION_SECRET` | Generate once with `openssl rand -hex 32`. Keep the same secret across restarts and server instances. |
|
|
23
|
+
| `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`. Set this to the claim your provider uses if your policy checks roles. |
|
|
25
|
+
|
|
26
|
+
## Create the core
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// auth.server.ts
|
|
32
|
+
import { createOidcCore } from '@lpshanley/oidc-start-core'
|
|
33
|
+
import { hasRole } from '@lpshanley/oidc-start-core/models'
|
|
34
|
+
import { revocations } from './revocations.js'
|
|
35
|
+
|
|
36
|
+
export const auth = createOidcCore({
|
|
37
|
+
cookiePrefix: 'myapp',
|
|
38
|
+
callbackPath: '/callback',
|
|
39
|
+
postLogoutPath: '/',
|
|
40
|
+
allowSession: user => hasRole(user, 'members'),
|
|
41
|
+
revocations,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
auth.assertConfigured()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`./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)
|
|
48
|
+
|
|
49
|
+
## Wire sign-in and callback
|
|
50
|
+
|
|
51
|
+
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 `/`.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { SessionAccessDeniedError } from '@lpshanley/oidc-start-core'
|
|
55
|
+
import { auth } from './auth.server.js'
|
|
56
|
+
|
|
57
|
+
function authHeaders(cookies: string[]) {
|
|
58
|
+
const headers = new Headers({ 'Cache-Control': 'no-store' })
|
|
59
|
+
for (const cookie of cookies) headers.append('Set-Cookie', cookie)
|
|
60
|
+
return headers
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function signIn(request: Request) {
|
|
64
|
+
const { cookies, setCookies } = await auth.resolve(request)
|
|
65
|
+
const flow = await auth.signIn(request, cookies, '/')
|
|
66
|
+
const headers = authHeaders([...setCookies, ...flow.setCookies])
|
|
67
|
+
headers.set('Location', flow.url)
|
|
68
|
+
return new Response(null, { status: 302, headers })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function callback(request: Request) {
|
|
72
|
+
const { cookies, setCookies } = await auth.resolve(request)
|
|
73
|
+
const result = await auth.callback(request, cookies)
|
|
74
|
+
const headers = authHeaders([...setCookies, ...result.setCookies])
|
|
75
|
+
if (!result.ok) {
|
|
76
|
+
const denied = result.error instanceof SessionAccessDeniedError
|
|
77
|
+
return new Response(denied ? 'Access denied.' : 'Please sign in again.', {
|
|
78
|
+
status: denied ? 403 : 400, headers,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
headers.set('Location', result.location)
|
|
82
|
+
return new Response(null, { status: 302, headers })
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
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
|
+
|
|
88
|
+
## End a session
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
| Method | Storage rule |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `revoke(sid, until)` | Mark this app session as revoked through its expiry. |
|
|
97
|
+
| `revokeIdpSession(idpSid, until)` | Mark the provider session as revoked. Atomically keep the later expiry. |
|
|
98
|
+
| `revokeAllFor(sub, cutoff)` | Atomically keep the greatest cutoff for the subject. |
|
|
99
|
+
| `isRevoked({ sid, idpSid, sub, issuedAt })` | Return true for a live revocation of either session ID, or when `issuedAt <= cutoff` for this subject. |
|
|
100
|
+
|
|
101
|
+
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)
|
|
102
|
+
|
|
103
|
+
## Obtain a separate provider grant
|
|
104
|
+
|
|
105
|
+
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`.
|
|
106
|
+
|
|
107
|
+
1. Call `auth.startGrant(request, cookies, returnPathname)`, send its cookies, and redirect to its `url`.
|
|
108
|
+
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.
|
|
109
|
+
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
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AuthUser } from "./models.js";
|
|
3
|
+
import type { SessionRevocations } from "./revocation.js";
|
|
4
|
+
export type AuthEnvironment = Readonly<Record<string, string | undefined>>;
|
|
5
|
+
export type AuthEnvironmentProvider = () => AuthEnvironment;
|
|
6
|
+
export declare const authServiceOptionsSchema: z.ZodObject<{
|
|
7
|
+
cookiePrefix: z.ZodDefault<z.ZodString>;
|
|
8
|
+
sessionMaxAgeSeconds: z.ZodDefault<z.ZodNumber>;
|
|
9
|
+
sessionIdleSeconds: z.ZodDefault<z.ZodNumber>;
|
|
10
|
+
callbackPath: z.ZodDefault<z.ZodString>;
|
|
11
|
+
grantCallbackPath: z.ZodDefault<z.ZodString>;
|
|
12
|
+
postLogoutPath: z.ZodDefault<z.ZodString>;
|
|
13
|
+
providerTimeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
14
|
+
}, z.core.$strip>;
|
|
15
|
+
export type AuthServiceOptions = z.input<typeof authServiceOptionsSchema>;
|
|
16
|
+
export declare const sessionConfigurationSchema: z.ZodObject<{
|
|
17
|
+
SESSION_SECRET: z.ZodString;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
export declare const oidcConfigurationSchema: z.ZodObject<{
|
|
20
|
+
OIDC_ISSUER: z.ZodPipe<z.ZodString, z.ZodURL>;
|
|
21
|
+
OIDC_CLIENT_ID: z.ZodString;
|
|
22
|
+
OIDC_CLIENT_SECRET: z.ZodString;
|
|
23
|
+
OIDC_SCOPES: z.ZodPreprocess<z.ZodDefault<z.ZodString>, unknown>;
|
|
24
|
+
OIDC_ROLES_CLAIM: z.ZodPreprocess<z.ZodDefault<z.ZodString>, unknown>;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
export type OidcConfiguration = z.output<typeof oidcConfigurationSchema>;
|
|
27
|
+
export declare const authOriginConfigurationSchema: z.ZodObject<{
|
|
28
|
+
APP_ORIGIN: z.ZodPreprocess<z.ZodOptional<z.ZodURL>, unknown>;
|
|
29
|
+
OIDC_REDIRECT_URI: z.ZodCatch<z.ZodOptional<z.ZodURL>>;
|
|
30
|
+
NODE_ENV: z.ZodOptional<z.ZodString>;
|
|
31
|
+
}, z.core.$strip>;
|
|
32
|
+
export declare function assertOidcConfigured(env: AuthEnvironment): void;
|
|
33
|
+
/**
|
|
34
|
+
* The caller's request context, read from `context.requestContext` when an
|
|
35
|
+
* earlier request middleware provides one.
|
|
36
|
+
*/
|
|
37
|
+
export type OidcCallContext = {
|
|
38
|
+
correlationId?: string;
|
|
39
|
+
deadline?: number;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
};
|
|
42
|
+
/** A failure the library recovered from or reported to the browser. */
|
|
43
|
+
export type OidcErrorEvent = {
|
|
44
|
+
operation: "sign-in" | "callback" | "refresh" | "end-session" | "token-revocation" | "backchannel-logout";
|
|
45
|
+
error: unknown;
|
|
46
|
+
context?: OidcCallContext;
|
|
47
|
+
};
|
|
48
|
+
export type OidcMiddlewareOptions = AuthServiceOptions & {
|
|
49
|
+
/** The host decides who may start or keep a session. */
|
|
50
|
+
allowSession: (user: AuthUser) => boolean;
|
|
51
|
+
/** Server-side record of ended sessions. */
|
|
52
|
+
revocations: SessionRevocations;
|
|
53
|
+
/** Read configuration inside each request. Defaults to process.env. */
|
|
54
|
+
environment?: AuthEnvironmentProvider;
|
|
55
|
+
/**
|
|
56
|
+
* Receives provider and flow failures. The error may carry provider
|
|
57
|
+
* responses, so redact it before logging. Defaults to logging the
|
|
58
|
+
* operation and request ID only.
|
|
59
|
+
*/
|
|
60
|
+
onError?: (event: OidcErrorEvent) => void;
|
|
61
|
+
};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const authServiceOptionsSchema = z.object({
|
|
3
|
+
/** Cookie names start with this prefix. Defaults to `oidc`. */
|
|
4
|
+
cookiePrefix: z
|
|
5
|
+
.string()
|
|
6
|
+
.regex(/^[A-Za-z0-9_-]+$/, "cookiePrefix must contain only letters, numbers, underscores or hyphens")
|
|
7
|
+
.default("oidc"),
|
|
8
|
+
/** Application session lifetime in seconds. Defaults to 14 days. */
|
|
9
|
+
sessionMaxAgeSeconds: z
|
|
10
|
+
.number()
|
|
11
|
+
.int("sessionMaxAgeSeconds must be a positive integer")
|
|
12
|
+
.positive("sessionMaxAgeSeconds must be a positive integer")
|
|
13
|
+
.default(60 * 60 * 24 * 14),
|
|
14
|
+
/** Session lifetime without requests, in seconds. Defaults to 12 hours. */
|
|
15
|
+
sessionIdleSeconds: z
|
|
16
|
+
.number()
|
|
17
|
+
.int()
|
|
18
|
+
.positive()
|
|
19
|
+
.default(60 * 60 * 12),
|
|
20
|
+
/** Callback path when OIDC_REDIRECT_URI is unset. */
|
|
21
|
+
callbackPath: z.string().default("/api/auth/callback"),
|
|
22
|
+
/** Separate return address for app-managed grants. */
|
|
23
|
+
grantCallbackPath: z.string().default("/api/auth/grant-callback"),
|
|
24
|
+
/** Application path after provider logout. Defaults to `/login`. */
|
|
25
|
+
postLogoutPath: z.string().default("/login"),
|
|
26
|
+
/** Timeout for each provider request. Defaults to 5 seconds. */
|
|
27
|
+
providerTimeoutMs: z.number().int().positive().default(5_000),
|
|
28
|
+
});
|
|
29
|
+
export const sessionConfigurationSchema = z.object({
|
|
30
|
+
SESSION_SECRET: z
|
|
31
|
+
.string({ error: "Missing required environment variable: SESSION_SECRET" })
|
|
32
|
+
.min(1, "Missing required environment variable: SESSION_SECRET")
|
|
33
|
+
.min(32, "SESSION_SECRET must be at least 32 characters"),
|
|
34
|
+
});
|
|
35
|
+
export const oidcConfigurationSchema = z.object({
|
|
36
|
+
OIDC_ISSUER: z
|
|
37
|
+
.string({
|
|
38
|
+
error: "Missing required environment variable: OIDC_ISSUER",
|
|
39
|
+
})
|
|
40
|
+
.min(1, "Missing required environment variable: OIDC_ISSUER")
|
|
41
|
+
.pipe(z.url({ error: "OIDC_ISSUER must be a valid URL" })),
|
|
42
|
+
OIDC_CLIENT_ID: z
|
|
43
|
+
.string({
|
|
44
|
+
error: "Missing required environment variable: OIDC_CLIENT_ID",
|
|
45
|
+
})
|
|
46
|
+
.min(1, "Missing required environment variable: OIDC_CLIENT_ID"),
|
|
47
|
+
OIDC_CLIENT_SECRET: z
|
|
48
|
+
.string({
|
|
49
|
+
error: "Missing required environment variable: OIDC_CLIENT_SECRET",
|
|
50
|
+
})
|
|
51
|
+
.min(1, "Missing required environment variable: OIDC_CLIENT_SECRET"),
|
|
52
|
+
/** Space-separated scopes; offline_access requests a refresh token. */
|
|
53
|
+
OIDC_SCOPES: z.preprocess((value) => (value === "" ? undefined : value), z.string().default("openid profile email offline_access")),
|
|
54
|
+
/** The claim holding the user's roles, read into `AuthUser.roles`. */
|
|
55
|
+
OIDC_ROLES_CLAIM: z.preprocess((value) => (value === "" ? undefined : value), z.string().default("roles")),
|
|
56
|
+
});
|
|
57
|
+
export const authOriginConfigurationSchema = z.object({
|
|
58
|
+
APP_ORIGIN: z.preprocess((value) => (value === "" ? undefined : value), z.url({ error: "APP_ORIGIN must be a valid URL" }).optional()),
|
|
59
|
+
OIDC_REDIRECT_URI: z.url().optional().catch(undefined),
|
|
60
|
+
NODE_ENV: z.string().optional(),
|
|
61
|
+
});
|
|
62
|
+
export function assertOidcConfigured(env) {
|
|
63
|
+
const issues = [
|
|
64
|
+
oidcConfigurationSchema,
|
|
65
|
+
sessionConfigurationSchema,
|
|
66
|
+
authOriginConfigurationSchema,
|
|
67
|
+
].flatMap((schema) => schema.safeParse(env).error?.issues ?? []);
|
|
68
|
+
if (issues.length) {
|
|
69
|
+
throw new Error(`Invalid OIDC configuration: ${issues.map((issue) => issue.message).join("; ")}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type CookieOptions = {
|
|
2
|
+
path?: string;
|
|
3
|
+
httpOnly?: boolean;
|
|
4
|
+
secure?: boolean;
|
|
5
|
+
sameSite?: "lax" | "strict" | "none";
|
|
6
|
+
maxAge?: number;
|
|
7
|
+
};
|
|
8
|
+
/** Browsers cap a cookie near 4096 bytes; leave room for name and attributes. */
|
|
9
|
+
export declare const CHUNK_SIZE = 3800;
|
|
10
|
+
export declare function parseCookies(header: string | null | undefined): Map<string, string>;
|
|
11
|
+
export declare function serializeCookie(name: string, value: string, options: CookieOptions): string;
|
|
12
|
+
/** Reads `name`, or its contiguous `name.0`, `name.1`, … chunks. */
|
|
13
|
+
export declare function readChunked(cookies: Map<string, string>, name: string): string | undefined;
|
|
14
|
+
/** Set-Cookie lines that delete `name` and every chunk of it. */
|
|
15
|
+
export declare function clearChunked(cookies: Map<string, string>, name: string, options: CookieOptions): string[];
|
|
16
|
+
/**
|
|
17
|
+
* Set-Cookie lines that store `value` under `name`, splitting it into
|
|
18
|
+
* `name.0..n` chunks when it is too large for one cookie, and deleting any
|
|
19
|
+
* stale cookie or chunk left from a previous size.
|
|
20
|
+
*/
|
|
21
|
+
export declare function writeChunked(cookies: Map<string, string>, name: string, value: string, options: CookieOptions): string[];
|
package/dist/cookies.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Browsers cap a cookie near 4096 bytes; leave room for name and attributes. */
|
|
2
|
+
export const CHUNK_SIZE = 3800;
|
|
3
|
+
export function parseCookies(header) {
|
|
4
|
+
const cookies = new Map();
|
|
5
|
+
for (const part of header?.split(";") ?? []) {
|
|
6
|
+
const index = part.indexOf("=");
|
|
7
|
+
if (index < 1)
|
|
8
|
+
continue;
|
|
9
|
+
const name = part.slice(0, index).trim();
|
|
10
|
+
const raw = part.slice(index + 1).trim();
|
|
11
|
+
try {
|
|
12
|
+
cookies.set(name, decodeURIComponent(raw));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
cookies.set(name, raw);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return cookies;
|
|
19
|
+
}
|
|
20
|
+
export function serializeCookie(name, value, options) {
|
|
21
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
22
|
+
if (options.maxAge !== undefined)
|
|
23
|
+
parts.push(`Max-Age=${options.maxAge}`);
|
|
24
|
+
parts.push(`Path=${options.path ?? "/"}`);
|
|
25
|
+
if (options.httpOnly)
|
|
26
|
+
parts.push("HttpOnly");
|
|
27
|
+
if (options.secure)
|
|
28
|
+
parts.push("Secure");
|
|
29
|
+
if (options.sameSite) {
|
|
30
|
+
parts.push(`SameSite=${options.sameSite[0]?.toUpperCase()}${options.sameSite.slice(1)}`);
|
|
31
|
+
}
|
|
32
|
+
return parts.join("; ");
|
|
33
|
+
}
|
|
34
|
+
function chunkNames(cookies, name) {
|
|
35
|
+
const pattern = new RegExp(`^${name.replace(/[.-]/g, "\\$&")}\\.(\\d+)$`);
|
|
36
|
+
return [...cookies.keys()]
|
|
37
|
+
.map((key) => ({ key, index: Number(pattern.exec(key)?.[1]) }))
|
|
38
|
+
.filter(({ index }) => Number.isInteger(index))
|
|
39
|
+
.sort((a, b) => a.index - b.index);
|
|
40
|
+
}
|
|
41
|
+
/** Reads `name`, or its contiguous `name.0`, `name.1`, … chunks. */
|
|
42
|
+
export function readChunked(cookies, name) {
|
|
43
|
+
const whole = cookies.get(name);
|
|
44
|
+
if (whole !== undefined)
|
|
45
|
+
return whole;
|
|
46
|
+
const chunks = chunkNames(cookies, name);
|
|
47
|
+
if (chunks.length === 0 || chunks.some(({ index }, i) => index !== i)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return chunks.map(({ key }) => cookies.get(key)).join("");
|
|
51
|
+
}
|
|
52
|
+
/** Set-Cookie lines that delete `name` and every chunk of it. */
|
|
53
|
+
export function clearChunked(cookies, name, options) {
|
|
54
|
+
const expired = { ...options, maxAge: 0 };
|
|
55
|
+
const keys = [
|
|
56
|
+
...(cookies.has(name) ? [name] : []),
|
|
57
|
+
...chunkNames(cookies, name).map(({ key }) => key),
|
|
58
|
+
];
|
|
59
|
+
return keys.map((key) => serializeCookie(key, "", expired));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Set-Cookie lines that store `value` under `name`, splitting it into
|
|
63
|
+
* `name.0..n` chunks when it is too large for one cookie, and deleting any
|
|
64
|
+
* stale cookie or chunk left from a previous size.
|
|
65
|
+
*/
|
|
66
|
+
export function writeChunked(cookies, name, value, options) {
|
|
67
|
+
const encoded = encodeURIComponent(value);
|
|
68
|
+
if (encoded.length <= CHUNK_SIZE) {
|
|
69
|
+
return [
|
|
70
|
+
serializeCookie(name, value, options),
|
|
71
|
+
...clearChunked(new Map(chunkNames(cookies, name).map(({ key }) => [key, ""])), name, options),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
// Split the plain value so no percent-escape is cut in half.
|
|
75
|
+
const chunks = [];
|
|
76
|
+
let current = "";
|
|
77
|
+
for (const character of value) {
|
|
78
|
+
if (encodeURIComponent(current + character).length > CHUNK_SIZE) {
|
|
79
|
+
chunks.push(current);
|
|
80
|
+
current = "";
|
|
81
|
+
}
|
|
82
|
+
current += character;
|
|
83
|
+
}
|
|
84
|
+
if (current)
|
|
85
|
+
chunks.push(current);
|
|
86
|
+
const written = chunks.map((chunk, index) => serializeCookie(`${name}.${index}`, chunk, options));
|
|
87
|
+
const stale = clearChunked(cookies, name, options).filter((line) => {
|
|
88
|
+
const key = line.slice(0, line.indexOf("="));
|
|
89
|
+
const index = Number(key.slice(name.length + 1));
|
|
90
|
+
return key === name || index >= chunks.length;
|
|
91
|
+
});
|
|
92
|
+
return [...written, ...stale];
|
|
93
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { type OidcCallContext, type OidcErrorEvent, type OidcMiddlewareOptions } from "./config.js";
|
|
2
|
+
import { type AuthUser } from "./models.js";
|
|
3
|
+
import { type TokenSet } from "./provider.js";
|
|
4
|
+
import { type Session } from "./session.js";
|
|
5
|
+
export type OidcAuth = {
|
|
6
|
+
user: AuthUser | null;
|
|
7
|
+
};
|
|
8
|
+
/** The caller's deadline passed before the provider answered. */
|
|
9
|
+
export declare class OidcDeadlineError extends Error {
|
|
10
|
+
name: string;
|
|
11
|
+
constructor();
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Settles with the work, or rejects once the caller's deadline passes. Each
|
|
15
|
+
* provider request also has its own timeout, so abandoned work still ends.
|
|
16
|
+
*/
|
|
17
|
+
export declare function withinDeadline<T>(work: Promise<T>, context?: OidcCallContext, now?: number): Promise<T>;
|
|
18
|
+
/** The provider authenticated a user whom the host denied a session. */
|
|
19
|
+
export declare class SessionAccessDeniedError extends Error {
|
|
20
|
+
name: string;
|
|
21
|
+
constructor();
|
|
22
|
+
}
|
|
23
|
+
export type CallbackResult = {
|
|
24
|
+
ok: true;
|
|
25
|
+
location: string;
|
|
26
|
+
setCookies: string[];
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: unknown;
|
|
30
|
+
setCookies: string[];
|
|
31
|
+
};
|
|
32
|
+
export type GrantResult = {
|
|
33
|
+
ok: true;
|
|
34
|
+
user: AuthUser;
|
|
35
|
+
tokens: TokenSet;
|
|
36
|
+
returnPathname: string;
|
|
37
|
+
setCookies: string[];
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
error: unknown;
|
|
41
|
+
setCookies: string[];
|
|
42
|
+
};
|
|
43
|
+
export type GrantRefresh = {
|
|
44
|
+
status: "refreshed";
|
|
45
|
+
tokens: TokenSet;
|
|
46
|
+
} | {
|
|
47
|
+
status: "refused";
|
|
48
|
+
};
|
|
49
|
+
export declare function createOidcCore(options: OidcMiddlewareOptions): {
|
|
50
|
+
/** Reads, and when needed refreshes or clears, the request's session. */
|
|
51
|
+
resolve(request: Request, context?: OidcCallContext): Promise<{
|
|
52
|
+
cookies: Map<string, string>;
|
|
53
|
+
session: {
|
|
54
|
+
user: {
|
|
55
|
+
sub: string;
|
|
56
|
+
email: string;
|
|
57
|
+
name: string | null;
|
|
58
|
+
picture?: string | null | undefined;
|
|
59
|
+
roles?: string[] | undefined;
|
|
60
|
+
};
|
|
61
|
+
accessToken: string;
|
|
62
|
+
expiresAt: number;
|
|
63
|
+
sid: string;
|
|
64
|
+
issuedAt: number;
|
|
65
|
+
lastSeenAt: number;
|
|
66
|
+
refreshToken?: string | undefined;
|
|
67
|
+
idToken?: string | undefined;
|
|
68
|
+
idpSid?: string | undefined;
|
|
69
|
+
} | null;
|
|
70
|
+
setCookies: string[];
|
|
71
|
+
}>;
|
|
72
|
+
/** An authorization URL plus the flow's verifier cookie. */
|
|
73
|
+
signIn(request: Request, cookies: Map<string, string>, returnPathname?: string | null, context?: OidcCallContext): Promise<{
|
|
74
|
+
url: string;
|
|
75
|
+
setCookies: string[];
|
|
76
|
+
}>;
|
|
77
|
+
/** Verifies the flow, exchanges the code, and issues the session. */
|
|
78
|
+
callback(request: Request, cookies: Map<string, string>, context?: OidcCallContext): Promise<CallbackResult>;
|
|
79
|
+
/**
|
|
80
|
+
* Starts a grant: an authorization like sign-in whose tokens the app
|
|
81
|
+
* keeps for itself, returning on `grantCallbackPath`. It writes no
|
|
82
|
+
* session, so the browser's own session is untouched.
|
|
83
|
+
*/
|
|
84
|
+
startGrant(request: Request, cookies: Map<string, string>, returnPathname: string, context?: OidcCallContext): Promise<{
|
|
85
|
+
url: string;
|
|
86
|
+
setCookies: string[];
|
|
87
|
+
}>;
|
|
88
|
+
/** Verifies a grant's callback and exchanges its code for tokens. */
|
|
89
|
+
finishGrant(request: Request, cookies: Map<string, string>, context?: OidcCallContext): Promise<GrantResult>;
|
|
90
|
+
/**
|
|
91
|
+
* Refreshes a grant's tokens. `refused` means the provider ended the
|
|
92
|
+
* grant (`invalid_grant`); any other failure throws, since the provider
|
|
93
|
+
* may only be unreachable.
|
|
94
|
+
*/
|
|
95
|
+
refreshGrant(refreshToken: string, context?: OidcCallContext): Promise<GrantRefresh>;
|
|
96
|
+
/**
|
|
97
|
+
* Ends the session server-side (every session of the user with
|
|
98
|
+
* `everywhere`), clears the cookie, and returns the provider logout URL.
|
|
99
|
+
* Throws when the revocation cannot be recorded, so a sign-out is never
|
|
100
|
+
* reported done while the cookie would still work.
|
|
101
|
+
*/
|
|
102
|
+
signOut(request: Request, cookies: Map<string, string>, session: Session | null, context?: OidcCallContext, everywhere?: boolean): Promise<{
|
|
103
|
+
location: string;
|
|
104
|
+
setCookies: string[];
|
|
105
|
+
}>;
|
|
106
|
+
/**
|
|
107
|
+
* Handles a provider's back-channel logout: verifies the posted
|
|
108
|
+
* logout_token and ends the sessions it names. Answers 400 for an
|
|
109
|
+
* invalid token and 503 when the provider's keys or the revocation
|
|
110
|
+
* store cannot be reached, so the provider can retry.
|
|
111
|
+
*/
|
|
112
|
+
backchannelLogout(request: Request, context?: OidcCallContext): Promise<{
|
|
113
|
+
status: 200 | 400 | 503;
|
|
114
|
+
}>;
|
|
115
|
+
/** Reports a failure through the configured `onError`. */
|
|
116
|
+
report: (event: OidcErrorEvent) => void;
|
|
117
|
+
/**
|
|
118
|
+
* Throws one error naming every missing or invalid setting, so the
|
|
119
|
+
* app can refuse to serve before the first sign-in needs them.
|
|
120
|
+
*/
|
|
121
|
+
assertConfigured(): void;
|
|
122
|
+
};
|
|
123
|
+
export type OidcCore = ReturnType<typeof createOidcCore>;
|