@lpshanley/oidc-start-core 0.0.0-stage → 0.1.0-next.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 CHANGED
@@ -1,3 +1,25 @@
1
- # Temporary Holding Version
1
+ # @lpshanley/oidc-start-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
4
+
5
+ ```ts
6
+ import { createOidcCore } from '@lpshanley/oidc-start-core'
7
+ import { hasRole } from '@lpshanley/oidc-start-core/models'
8
+
9
+ const auth = createOidcCore({
10
+ cookiePrefix: 'myapp',
11
+ callbackPath: '/auth/callback',
12
+ postLogoutPath: '/signed-out',
13
+ allowSession: user => hasRole(user, 'members'),
14
+ revocations: mySessionRevocationStore,
15
+ })
16
+ auth.assertConfigured()
17
+ ```
18
+
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.
20
+
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.
22
+
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.
24
+
25
+ The `/models` entry is safe to import in browser code. Credentials and session management belong on the server.
@@ -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[];
@@ -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>;