@lpshanley/oidc-start-react 0.1.0 → 0.3.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
@@ -8,10 +8,10 @@ For multi-replica deployment, see the [production guide](https://github.com/lpsh
8
8
 
9
9
  ## 1. Install and configure the server
10
10
 
11
- Use Node.js 22.12 or newer. Your app supplies `@tanstack/react-start@^1.169.2` and `@tanstack/react-router@^1.170.44`. Install both OIDC packages because your app imports the core directly.
11
+ Use Node.js 22.12 or newer. Your app supplies `@tanstack/react-start@^1.169.2` and `@tanstack/react-router@^1.170.44`. Install the React adapter. It brings in the OIDC core as a dependency.
12
12
 
13
13
  ```sh
14
- npm install @lpshanley/oidc-start-react @lpshanley/oidc-start-core
14
+ npm install @lpshanley/oidc-start-react
15
15
  ```
16
16
 
17
17
  Register a confidential web client with your OIDC provider. If your app runs at `http://localhost:5173`, register these exact URLs. Use your public HTTPS origin in production.
@@ -32,7 +32,7 @@ OIDC_CLIENT_SECRET=your-client-secret
32
32
  SESSION_SECRET=
33
33
  ```
34
34
 
35
- For local development, copy the example’s `auth.server.ts` into `src/auth.server.ts`. It exports an `auth` instance configured with `callbackPath: '/callback'` and `postLogoutPath: '/'`. The example admits any authenticated user and keeps revocations in memory. For deployment, set `allowSession` to your access policy and use a durable revocation store shared by all instances. [Copy auth.server.ts](https://github.com/lpshanley/oidc-start/blob/main/examples/tanstack-start/src/auth.server.ts) · [Core configuration and storage](https://www.npmjs.com/package/@lpshanley/oidc-start-core)
35
+ Import `createOidcCore` and the `SessionRevocations` type from `@lpshanley/oidc-start-react/server` in your server module. For local development, copy the example’s `auth.server.ts` into `src/auth.server.ts`. It exports an `auth` instance configured with `callbackPath: '/callback'` and `postLogoutPath: '/'`. The example admits any authenticated user and keeps revocations in memory. For deployment, set `allowSession` to your access policy and use a durable revocation store shared by all instances. [Copy auth.server.ts](https://github.com/lpshanley/oidc-start/blob/main/examples/tanstack-start/src/auth.server.ts) · [Core configuration and storage](https://www.npmjs.com/package/@lpshanley/oidc-start-core)
36
36
 
37
37
  ## 2. Register the request middleware
38
38
 
@@ -69,7 +69,7 @@ export const Route = createFileRoute('/callback')({
69
69
  In `src/routes/index.tsx`, call `getAuth()` from the loader. It returns `{ user }`, with `user: null` for a signed-out visitor. Bind `signOut` with `useServerFn` so TanStack follows the redirect to the provider when the user clicks the button.
70
70
 
71
71
  ```tsx
72
- import { displayName } from '@lpshanley/oidc-start-core/models'
72
+ import { displayName } from '@lpshanley/oidc-start-react'
73
73
  import { getAuth, getSignInUrl, signOut } from '@lpshanley/oidc-start-react/functions'
74
74
  import { createFileRoute } from '@tanstack/react-router'
75
75
  import { useServerFn } from '@tanstack/react-start'
@@ -96,10 +96,14 @@ function Home() {
96
96
  }
97
97
  ```
98
98
 
99
+ For shared UI such as an account menu, load `getAuth()` in the root route and wrap `<Outlet />` in `<OidcAuthProvider auth={Route.useLoaderData()}>`. Import the provider and `useAuth` from `@lpshanley/oidc-start-react/client`. Components can then call `const { user, signIn, signOut } = useAuth()` and bind buttons with `onClick={() => signIn()}` or `onClick={() => signOut()}`. The first render uses the loader's user, and subsequent loader updates flow through the provider. [Example root route](https://github.com/lpshanley/oidc-start/blob/main/examples/tanstack-start/src/routes/__root.tsx)
100
+
99
101
  **Try it.** Open `/` in a fresh browser session and choose **Sign in**. After the provider redirects you back, your name should appear and survive a reload. Choose **Sign out**, finish logout at the provider if prompted, and reload. You should see **Sign in** again.
100
102
 
101
103
  To return someone to a specific page, pass a local path when starting sign-in, for example `getSignInUrl({ data: { returnPathname: '/account' } })`.
102
104
 
105
+ To prefill an account, pass `loginHint`. For an account picker, call `signIn({ prompt: 'select_account' })`. The hook, `getSignInUrl({ data: options })`, and `handleSignInRoute(options)` accept the same sign-in options. Supported prompts are `login`, `consent`, `select_account`, and `none`. Omitting `prompt` leaves the provider's usual SSO behavior in place.
106
+
103
107
  ## HTTP routes and server access
104
108
 
105
109
  The buttons above use server functions. If you prefer ordinary links and forms, mount these handlers from `@lpshanley/oidc-start-react` behind the same OIDC middleware. Choose the paths in your app.
@@ -112,3 +116,13 @@ The buttons above use server functions. If you prefer ordinary links and forms,
112
116
  | POST | `handleBackchannelLogoutRoute()` | Register this URL with your provider for back-channel logout. The provider sends a signed logout token without a browser Origin header. Keep the browser CSRF check scoped to the browser routes. |
113
117
 
114
118
  In a server handler, import `getOidcContext` from `@lpshanley/oidc-start-react/server` to access the resolved session and core for the current request. Use `getAuth()` from `/functions` when a loader or component only needs the user.
119
+
120
+ ### Customize callback failures
121
+
122
+ Pass `onError({ reason, request })` to `handleCallbackRoute` to return a custom response. It takes precedence over `errorRedirectUrl`. A provider cancellation is `access_denied`, and a missing verifier (for example after its cookie expires) is `sign_in_expired`. Other reasons are `invalid_callback`, `invalid_state`, `provider_error`, `token_exchange_failed`, and `session_failed`. The middleware still clears the failed flow's verifier. A rejection by your own `allowSession` policy takes its separate 403 path.
123
+
124
+ ### Require a recent login
125
+
126
+ Inside the server handler for a sensitive action, call `checkRecentAuth({ maxAge: 300 })` from `@lpshanley/oidc-start-react/server` before performing the action. If `isStale` is true, stop the mutation and offer `signIn({ maxAge: 300, returnPathname: '/settings' })`. Check again when the user submits the action after returning. Pick the maximum age in the server's policy.
127
+
128
+ The age is in seconds and comes from the provider's ID-token `auth_time`. Signed-out users, existing sessions without that claim, and expired authentication all require sign-in. Token refresh preserves the original authentication time. A sign-in with `maxAge: 0` requests fresh authentication.
@@ -0,0 +1,26 @@
1
+ import { type ReactNode } from "react";
2
+ import type { AuthSession, SignInOptions } from "@lpshanley/oidc-start-core/models";
3
+ /** Supply the root loader's getAuth() result for SSR and client navigation. */
4
+ export declare function OidcAuthProvider({ auth, children }: {
5
+ auth: AuthSession;
6
+ children: ReactNode;
7
+ }): import("react").FunctionComponentElement<import("react").ProviderProps<{
8
+ user: {
9
+ sub: string;
10
+ email: string;
11
+ name: string | null;
12
+ picture?: string | null | undefined;
13
+ roles?: string[] | undefined;
14
+ } | null;
15
+ } | undefined>>;
16
+ export declare function useAuth(): {
17
+ user: {
18
+ sub: string;
19
+ email: string;
20
+ name: string | null;
21
+ picture?: string | null | undefined;
22
+ roles?: string[] | undefined;
23
+ } | null;
24
+ signIn(options?: SignInOptions): Promise<void>;
25
+ signOut: () => Promise<never>;
26
+ };
package/dist/client.js ADDED
@@ -0,0 +1,21 @@
1
+ import { createContext, createElement, useContext } from "react";
2
+ import { useServerFn } from "@tanstack/react-start";
3
+ import { getSignInUrl, signOut } from "./functions.js";
4
+ const AuthContext = createContext(undefined);
5
+ /** Supply the root loader's getAuth() result for SSR and client navigation. */
6
+ export function OidcAuthProvider({ auth, children }) {
7
+ return createElement(AuthContext.Provider, { value: auth }, children);
8
+ }
9
+ export function useAuth() {
10
+ const auth = useContext(AuthContext);
11
+ const signOutAndRedirect = useServerFn(signOut);
12
+ if (!auth)
13
+ throw new Error("useAuth() requires an OidcAuthProvider.");
14
+ return {
15
+ user: auth.user,
16
+ async signIn(options) {
17
+ window.location.assign(await getSignInUrl({ data: options }));
18
+ },
19
+ signOut: () => signOutAndRedirect(),
20
+ };
21
+ }
package/dist/context.d.ts CHANGED
@@ -15,3 +15,9 @@ export type OidcContext = {
15
15
  };
16
16
  /** The current request's OIDC context; throws outside the middleware. */
17
17
  export declare function getOidcContext(): OidcContext;
18
+ /** Check the resolved session inside the server handler that performs a sensitive action. */
19
+ export declare function checkRecentAuth({ maxAge }: {
20
+ maxAge: number;
21
+ }): {
22
+ isStale: boolean;
23
+ };
package/dist/context.js CHANGED
@@ -14,3 +14,14 @@ export function getOidcContext() {
14
14
  throw new Error(NOT_CONFIGURED);
15
15
  return context.oidc;
16
16
  }
17
+ /** Check the resolved session inside the server handler that performs a sensitive action. */
18
+ export function checkRecentAuth({ maxAge }) {
19
+ if (!Number.isSafeInteger(maxAge) || maxAge < 0) {
20
+ throw new RangeError("maxAge must be a nonnegative integer in seconds");
21
+ }
22
+ const authTime = getOidcContext().session?.authTime;
23
+ if (authTime === undefined)
24
+ return { isStale: true };
25
+ const age = Math.floor(Date.now() / 1000) - authTime;
26
+ return { isStale: !(age >= 0 && age <= maxAge) };
27
+ }
@@ -1,4 +1,3 @@
1
- import { z } from "zod";
2
1
  /** The signed-in user, or null. Never returns tokens. */
3
2
  export declare const getAuth: import("@tanstack/react-start").OptionalFetcher<undefined, undefined, Promise<{
4
3
  user: {
@@ -10,8 +9,16 @@ export declare const getAuth: import("@tanstack/react-start").OptionalFetcher<un
10
9
  } | null;
11
10
  }>>;
12
11
  /** An authorization URL; also queues the flow's verifier cookie. */
13
- export declare const getSignInUrl: import("@tanstack/react-start").OptionalFetcher<undefined, z.ZodOptional<z.ZodObject<{
14
- returnPathname: z.ZodOptional<z.ZodString>;
15
- }, z.core.$strip>>, Promise<string>>;
12
+ export declare const getSignInUrl: import("@tanstack/react-start").OptionalFetcher<undefined, import("zod").ZodOptional<import("zod").ZodObject<{
13
+ returnPathname: import("zod").ZodOptional<import("zod").ZodString>;
14
+ maxAge: import("zod").ZodOptional<import("zod").ZodNumber>;
15
+ loginHint: import("zod").ZodOptional<import("zod").ZodString>;
16
+ prompt: import("zod").ZodOptional<import("zod").ZodEnum<{
17
+ none: "none";
18
+ login: "login";
19
+ consent: "consent";
20
+ select_account: "select_account";
21
+ }>>;
22
+ }, import("zod/v4/core").$strip>>, Promise<string>>;
16
23
  /** Clears the session and redirects to provider logout. In a component, call via useServerFn. */
17
24
  export declare const signOut: import("@tanstack/react-start").OptionalFetcher<undefined, undefined, Promise<never>>;
package/dist/functions.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
- import { z } from "zod";
2
+ import { signInOptionsSchema } from "@lpshanley/oidc-start-core/models";
3
3
  // Server functions after WorkOS's server-functions.ts; bodies load lazily so
4
4
  // server code stays out of the client bundle.
5
5
  /** The signed-in user, or null. Never returns tokens. */
@@ -9,10 +9,10 @@ export const getAuth = createServerFn({ method: "GET" }).handler(async () => {
9
9
  });
10
10
  /** An authorization URL; also queues the flow's verifier cookie. */
11
11
  export const getSignInUrl = createServerFn({ method: "GET" })
12
- .validator(z.object({ returnPathname: z.string().optional() }).optional())
12
+ .validator(signInOptionsSchema.optional())
13
13
  .handler(async ({ data }) => {
14
14
  const { getSignInUrlBody } = await import("./functions.server.js");
15
- return getSignInUrlBody(data?.returnPathname);
15
+ return getSignInUrlBody(data);
16
16
  });
17
17
  /** Clears the session and redirects to provider logout. In a component, call via useServerFn. */
18
18
  export const signOut = createServerFn({ method: "POST" }).handler(async () => {
@@ -1,4 +1,4 @@
1
- import type { AuthSession } from "@lpshanley/oidc-start-core/models";
1
+ import type { AuthSession, SignInOptions } from "@lpshanley/oidc-start-core/models";
2
2
  export declare function getAuthBody(): AuthSession;
3
- export declare function getSignInUrlBody(returnPathname?: string): Promise<string>;
3
+ export declare function getSignInUrlBody(options?: SignInOptions): Promise<string>;
4
4
  export declare function signOutBody(): Promise<never>;
@@ -6,9 +6,9 @@ export function getAuthBody() {
6
6
  setResponseHeader("Cache-Control", "no-store");
7
7
  return { user: getOidcContext().auth().user };
8
8
  }
9
- export async function getSignInUrlBody(returnPathname) {
9
+ export async function getSignInUrlBody(options) {
10
10
  const context = getOidcContext();
11
- const { url, setCookies } = await context.core.signIn(context.request, context.cookies, returnPathname, context.call);
11
+ const { url, setCookies } = await context.core.signIn(context.request, context.cookies, options, context.call);
12
12
  for (const line of setCookies)
13
13
  context.appendSetCookie(line);
14
14
  return url;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export * from "@lpshanley/oidc-start-core/models";
1
2
  export type { OidcCallContext, OidcErrorEvent, OidcMiddlewareOptions, } from "@lpshanley/oidc-start-core/config";
2
3
  export { type OidcMiddlewareSetup, oidcMiddleware } from "./middleware.js";
3
- export { type CallbackRouteOptions, handleBackchannelLogoutRoute, handleCallbackRoute, handleSignInRoute, handleSignOutRoute, } from "./routes.js";
4
+ export { type CallbackRouteOptions, type SignInRouteOptions, handleBackchannelLogoutRoute, handleCallbackRoute, handleSignInRoute, handleSignOutRoute, } from "./routes.js";
5
+ export type { CallbackErrorReason } from "@lpshanley/oidc-start-core";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
+ export * from "@lpshanley/oidc-start-core/models";
1
2
  export { oidcMiddleware } from "./middleware.js";
2
3
  export { handleBackchannelLogoutRoute, handleCallbackRoute, handleSignInRoute, handleSignOutRoute, } from "./routes.js";
package/dist/routes.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import type { CallbackRouteOptions } from "./routes.server.js";
2
- export type { CallbackRouteOptions };
1
+ import type { CallbackRouteOptions, SignInRouteOptions } from "./routes.server.js";
2
+ export type { CallbackRouteOptions, SignInRouteOptions };
3
3
  type RouteHandler = (args: {
4
4
  request: Request;
5
5
  }) => Promise<Response>;
6
6
  /** GET: redirects to the provider; `?returnPathname=` sets the destination. */
7
- export declare function handleSignInRoute(options?: CallbackRouteOptions): RouteHandler;
7
+ export declare function handleSignInRoute(options?: SignInRouteOptions): RouteHandler;
8
8
  /** GET: completes sign-in and redirects to the sanitized return path. */
9
9
  export declare function handleCallbackRoute(options?: CallbackRouteOptions): RouteHandler;
10
10
  /** POST: the provider's back-channel logout; ends the sessions its token names. */
@@ -1,10 +1,21 @@
1
- export type CallbackRouteOptions = {
1
+ import type { CallbackErrorReason } from "@lpshanley/oidc-start-core";
2
+ import type { SignInOptions } from "@lpshanley/oidc-start-core/models";
3
+ type FailureOptions = {
2
4
  /** Where to send the browser when sign-in fails. Defaults to a 400. */
3
5
  errorRedirectUrl?: string;
4
6
  /** Shown when the host denies a session to an authenticated user. */
5
7
  accessDeniedMessage?: string;
6
8
  };
7
- export declare function signInRoute(request: Request, options: CallbackRouteOptions): Promise<Response>;
9
+ export type SignInRouteOptions = FailureOptions & SignInOptions;
10
+ export type CallbackRouteOptions = FailureOptions & {
11
+ /** Customize callback failures after the verifier cookie has been queued for cleanup. */
12
+ onError?: (failure: {
13
+ reason: CallbackErrorReason;
14
+ request: Request;
15
+ }) => Response | Promise<Response>;
16
+ };
17
+ export declare function signInRoute(request: Request, options: SignInRouteOptions): Promise<Response>;
8
18
  export declare function callbackRoute(request: Request, options: CallbackRouteOptions): Promise<Response>;
9
19
  export declare function signOutRoute(request: Request): Promise<Response>;
10
20
  export declare function backchannelLogoutRoute(request: Request): Promise<Response>;
21
+ export {};
@@ -1,16 +1,12 @@
1
1
  import { endSession } from "./sign-out.server.js";
2
2
  import { getOidcContext } from "./context.js";
3
- import { SessionAccessDeniedError } from "@lpshanley/oidc-start-core";
4
3
  function redirectTo(location, status) {
5
4
  return new Response(null, {
6
5
  status,
7
6
  headers: { Location: location, "Cache-Control": "no-store" },
8
7
  });
9
8
  }
10
- function failure(request, options, operation, error) {
11
- // Provider error details go to onError, never into the response.
12
- const context = getOidcContext();
13
- context.core.report({ operation, error, context: context.call });
9
+ function failure(request, options) {
14
10
  if (options.errorRedirectUrl) {
15
11
  return redirectTo(new URL(options.errorRedirectUrl, request.url).toString(), 303);
16
12
  }
@@ -22,13 +18,19 @@ function failure(request, options, operation, error) {
22
18
  export async function signInRoute(request, options) {
23
19
  const context = getOidcContext();
24
20
  try {
25
- const { url, setCookies } = await context.core.signIn(request, context.cookies, new URL(request.url).searchParams.get("returnPathname"), context.call);
21
+ const { url, setCookies } = await context.core.signIn(request, context.cookies, {
22
+ returnPathname: new URL(request.url).searchParams.get("returnPathname") ?? options.returnPathname,
23
+ loginHint: options.loginHint,
24
+ maxAge: options.maxAge,
25
+ prompt: options.prompt,
26
+ }, context.call);
26
27
  for (const line of setCookies)
27
28
  context.appendSetCookie(line);
28
29
  return redirectTo(url, 307);
29
30
  }
30
31
  catch (error) {
31
- return failure(request, options, "sign-in", error);
32
+ context.core.report({ operation: "sign-in", error, context: context.call });
33
+ return failure(request, options);
32
34
  }
33
35
  }
34
36
  export async function callbackRoute(request, options) {
@@ -38,13 +40,16 @@ export async function callbackRoute(request, options) {
38
40
  context.appendSetCookie(line);
39
41
  if (result.ok)
40
42
  return redirectTo(result.location, 307);
41
- if (result.error instanceof SessionAccessDeniedError) {
43
+ if (result.reason === "session_access_denied") {
42
44
  return new Response(options.accessDeniedMessage ?? "Your account does not have access.", {
43
45
  status: 403,
44
46
  headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" },
45
47
  });
46
48
  }
47
- return failure(request, options, "callback", result.error);
49
+ context.core.report({ operation: "callback", error: result.error, context: context.call });
50
+ if (options.onError)
51
+ return options.onError({ reason: result.reason, request });
52
+ return failure(request, options);
48
53
  }
49
54
  export async function signOutRoute(request) {
50
55
  if (request.method !== "POST") {
package/dist/server.d.ts CHANGED
@@ -1 +1,2 @@
1
- export { getOidcContext, type OidcContext } from "./context.js";
1
+ export * from "@lpshanley/oidc-start-core";
2
+ export { checkRecentAuth, getOidcContext, type OidcContext } from "./context.js";
package/dist/server.js CHANGED
@@ -1 +1,2 @@
1
- export { getOidcContext } from "./context.js";
1
+ export * from "@lpshanley/oidc-start-core";
2
+ export { checkRecentAuth, getOidcContext } from "./context.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lpshanley/oidc-start-react",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,6 +16,10 @@
16
16
  "types": "./dist/index.d.ts",
17
17
  "import": "./dist/index.js"
18
18
  },
19
+ "./client": {
20
+ "types": "./dist/client.d.ts",
21
+ "import": "./dist/client.js"
22
+ },
19
23
  "./functions": {
20
24
  "types": "./dist/functions.d.ts",
21
25
  "import": "./dist/functions.js"
@@ -36,7 +40,7 @@
36
40
  "node": ">=22.12.0"
37
41
  },
38
42
  "dependencies": {
39
- "@lpshanley/oidc-start-core": "0.1.0",
43
+ "@lpshanley/oidc-start-core": "0.3.0",
40
44
  "zod": "4.6.7"
41
45
  },
42
46
  "devDependencies": {
@@ -53,7 +57,13 @@
53
57
  },
54
58
  "peerDependencies": {
55
59
  "@tanstack/react-router": "^1.170.44",
56
- "@tanstack/react-start": "^1.169.2"
60
+ "@tanstack/react-start": "^1.169.2",
61
+ "react": "^18.0.0 || ^19.0.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "react": {
65
+ "optional": true
66
+ }
57
67
  },
58
68
  "scripts": {
59
69
  "build": "tsc -p tsconfig.build.json",