@lpshanley/oidc-start-react 0.4.2 → 0.5.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
@@ -21,7 +21,7 @@ Register a confidential web client with your OIDC provider. If your app runs at
21
21
  | Sign-in redirect URI | `http://localhost:5173/callback` |
22
22
  | Post-logout redirect URI | `http://localhost:5173/` |
23
23
 
24
- Set these values in your **server environment**. Generate `SESSION_SECRET` once with `openssl rand -hex 32` and reuse it across server instances. Your provider must supply the account’s email in the ID token or through UserInfo.
24
+ Set these values in your **server environment**. Generate `SESSION_SECRET` once with `openssl rand -hex 32` and reuse it across server instances. Email is optional. Use `displayName(user)` for a name, email, or neutral account label.
25
25
 
26
26
  ```dotenv
27
27
  APP_ORIGIN=http://localhost:5173
@@ -32,7 +32,7 @@ OIDC_CLIENT_SECRET=your-client-secret
32
32
  SESSION_SECRET=
33
33
  ```
34
34
 
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)
35
+ Import `createOidcCore` and the `SessionRegistry` 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 active sessions in memory. For deployment, set `allowSession` to your access policy and use a durable session registry 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
 
@@ -114,7 +114,7 @@ function Home() {
114
114
  }
115
115
  ```
116
116
 
117
- Call `signOut()` for the current session or `signOut({ everywhere: true })` for all of this user's sessions in this app. Both use the same CSRF-protected POST server function. If the revocation store is unavailable, the server returns 503 so the user can retry.
117
+ Call `signOut()` for the current session or `signOut({ everywhere: true })` for all of this user's sessions in this app. Use `signOut({ providerLogout: false })` to end only the application session. These options use the same CSRF-protected POST server function. If the session registry is unavailable, the server returns 503 so the user can retry.
118
118
 
119
119
  Use `useAuth().hasRole('admin')` to show role-specific UI. It returns false for signed-out users and sessions without a roles claim. Check authorization again in the server handler that reads private data or performs the action.
120
120
 
@@ -255,3 +255,7 @@ A guest or stale session is sent to the local sign-in endpoint with the same `ma
255
255
  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.
256
256
 
257
257
  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.
258
+
259
+ ## Upgrading to 0.5
260
+
261
+ See the [migration guide](https://github.com/lpshanley/oidc-start/blob/main/docs/migration-0.5.md). `getAuth` and guards distinguish absent sessions from denied admission (403) and temporary authentication outages (503); only an absent session starts automatic sign-in. User email is optional and issuer identity is explicit.
package/dist/client.d.ts CHANGED
@@ -1,23 +1,25 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
- import { type AuthSession, type SignInOptions } from "@lpshanley/oidc-start-core/models";
2
+ import { type AuthSession, type SignInOptions, type SignOutOptions } from "@lpshanley/oidc-start-core/models";
3
3
  /** Supply the root loader's getAuth() result for SSR and client navigation. */
4
4
  export declare function AuthProvider({ auth, children }: {
5
5
  auth: AuthSession;
6
6
  children: ReactNode;
7
7
  }): import("react").FunctionComponentElement<import("react").ProviderProps<{
8
8
  user: {
9
+ issuer: string;
9
10
  sub: string;
10
- email: string;
11
11
  name: string | null;
12
+ email?: string | undefined;
12
13
  picture?: string | null | undefined;
13
14
  roles?: string[] | undefined;
14
15
  } | null;
15
16
  } | undefined>>;
16
17
  /** The user supplied by the current root loader, or null when signed out. */
17
18
  export declare function useUser(): {
19
+ issuer: string;
18
20
  sub: string;
19
- email: string;
20
21
  name: string | null;
22
+ email?: string | undefined;
21
23
  picture?: string | null | undefined;
22
24
  roles?: string[] | undefined;
23
25
  } | null;
@@ -31,9 +33,10 @@ export declare function SignedOut({ children }: {
31
33
  }): ReactElement | null;
32
34
  export declare function useAuth(): {
33
35
  user: {
36
+ issuer: string;
34
37
  sub: string;
35
- email: string;
36
38
  name: string | null;
39
+ email?: string | undefined;
37
40
  picture?: string | null | undefined;
38
41
  roles?: string[] | undefined;
39
42
  } | null;
@@ -41,8 +44,6 @@ export declare function useAuth(): {
41
44
  signIn(options?: SignInOptions): Promise<void>;
42
45
  signOut: {
43
46
  (): Promise<never>;
44
- (options?: {
45
- everywhere?: boolean;
46
- }): Promise<never>;
47
+ (options?: SignOutOptions): Promise<never>;
47
48
  };
48
49
  };
package/dist/client.js CHANGED
@@ -26,7 +26,10 @@ export function useAuth() {
26
26
  const user = useUser();
27
27
  const signOutAndRedirect = useServerFn(signOut);
28
28
  function signOutUser(options) {
29
- return signOutAndRedirect({ data: { everywhere: options?.everywhere } });
29
+ return signOutAndRedirect({ data: options ? {
30
+ everywhere: options.everywhere === true,
31
+ providerLogout: options.providerLogout,
32
+ } : undefined });
30
33
  }
31
34
  return {
32
35
  user,
package/dist/context.d.ts CHANGED
@@ -12,6 +12,8 @@ export type OidcContext = {
12
12
  auth: () => OidcAuth;
13
13
  /** Queue a Set-Cookie line for the final response. */
14
14
  appendSetCookie: (line: string) => void;
15
+ /** Drop cookie writes from resolving the incoming session when a handler supersedes it. */
16
+ discardSessionUpdates?: () => void;
15
17
  };
16
18
  /** The current request's OIDC context; throws outside the middleware. */
17
19
  export declare function getOidcContext(): OidcContext;
@@ -2,9 +2,10 @@ import { z } from "zod";
2
2
  /** The signed-in user, or null. Never returns tokens. */
3
3
  export declare const getAuth: import("@tanstack/react-start").OptionalFetcher<undefined, undefined, Promise<{
4
4
  user: {
5
+ issuer: string;
5
6
  sub: string;
6
- email: string;
7
7
  name: string | null;
8
+ email?: string | undefined;
8
9
  picture?: string | null | undefined;
9
10
  roles?: string[] | undefined;
10
11
  } | null;
@@ -18,9 +19,10 @@ type GuardOptions = {
18
19
  /** Guard route navigation and supply a signed-in user to descendant routes. */
19
20
  export declare function requireAuth(options: GuardOptions): Promise<{
20
21
  user: {
22
+ issuer: string;
21
23
  sub: string;
22
- email: string;
23
24
  name: string | null;
25
+ email?: string | undefined;
24
26
  picture?: string | null | undefined;
25
27
  roles?: string[] | undefined;
26
28
  };
@@ -30,9 +32,10 @@ export declare function requireRecentAuth(options: GuardOptions & {
30
32
  maxAge: number;
31
33
  }): Promise<{
32
34
  user: {
35
+ issuer: string;
33
36
  sub: string;
34
- email: string;
35
37
  name: string | null;
38
+ email?: string | undefined;
36
39
  picture?: string | null | undefined;
37
40
  roles?: string[] | undefined;
38
41
  };
@@ -42,15 +45,11 @@ export declare const getSignInUrl: import("@tanstack/react-start").OptionalFetch
42
45
  returnPathname: z.ZodOptional<z.ZodString>;
43
46
  maxAge: z.ZodOptional<z.ZodNumber>;
44
47
  loginHint: z.ZodOptional<z.ZodString>;
45
- prompt: z.ZodOptional<z.ZodEnum<{
46
- none: "none";
47
- login: "login";
48
- consent: "consent";
49
- select_account: "select_account";
50
- }>>;
48
+ prompt: z.ZodOptional<z.ZodString>;
51
49
  }, z.core.$strip>>, Promise<string>>;
52
50
  /** Clears the session and redirects to provider logout. In a component, call via useServerFn. */
53
51
  export declare const signOut: import("@tanstack/react-start").OptionalFetcher<undefined, z.ZodOptional<z.ZodObject<{
54
52
  everywhere: z.ZodOptional<z.ZodBoolean>;
53
+ providerLogout: z.ZodOptional<z.ZodBoolean>;
55
54
  }, z.core.$strip>>, Promise<never>>;
56
55
  export {};
package/dist/functions.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
2
  import { redirect } from "@tanstack/react-router";
3
3
  import { z } from "zod";
4
- import { sanitizeReturnPath, signInOptionsSchema } from "@lpshanley/oidc-start-core/models";
4
+ import { sanitizeReturnPath, signInOptionsSchema, signOutOptionsSchema } from "@lpshanley/oidc-start-core/models";
5
5
  // Server functions after WorkOS's server-functions.ts; bodies load lazily so
6
6
  // server code stays out of the client bundle.
7
7
  /** The signed-in user, or null. Never returns tokens. */
@@ -45,8 +45,8 @@ export const getSignInUrl = createServerFn({ method: "GET" })
45
45
  });
46
46
  /** Clears the session and redirects to provider logout. In a component, call via useServerFn. */
47
47
  export const signOut = createServerFn({ method: "POST" })
48
- .validator(z.object({ everywhere: z.boolean().optional() }).optional())
48
+ .validator(signOutOptionsSchema.optional())
49
49
  .handler(async ({ data }) => {
50
50
  const { signOutBody } = await import("./functions.server.js");
51
- return signOutBody(data?.everywhere);
51
+ return signOutBody(data);
52
52
  });
@@ -1,5 +1,5 @@
1
- import type { AuthSession, SignInOptions } from "@lpshanley/oidc-start-core/models";
1
+ import type { AuthSession, SignInOptions, SignOutOptions } from "@lpshanley/oidc-start-core/models";
2
2
  export declare function getAuthBody(): AuthSession;
3
3
  export declare function getRecentAuthBody(maxAge: number): AuthSession;
4
4
  export declare function getSignInUrlBody(options?: SignInOptions): Promise<string>;
5
- export declare function signOutBody(everywhere?: boolean): Promise<never>;
5
+ export declare function signOutBody(options?: SignOutOptions): Promise<never>;
@@ -1,10 +1,19 @@
1
1
  import { redirect } from "@tanstack/react-router";
2
- import { setResponseHeader } from "@tanstack/react-start/server";
2
+ import { setResponseHeader, setResponseStatus } from "@tanstack/react-start/server";
3
3
  import { endSession } from "./sign-out.server.js";
4
4
  import { checkRecentAuth, getOidcContext } from "./context.js";
5
5
  export function getAuthBody() {
6
6
  setResponseHeader("Cache-Control", "no-store");
7
- return { user: getOidcContext().auth().user };
7
+ const auth = getOidcContext().auth();
8
+ if (auth.status === "denied" || auth.status === "unavailable") {
9
+ const status = auth.status === "denied" ? 403 : 503;
10
+ setResponseStatus(status);
11
+ // Loader errors must serialize during SSR. A thrown Response cannot.
12
+ throw Object.assign(new Error(auth.status === "denied"
13
+ ? "Your account does not have access."
14
+ : "Authentication is temporarily unavailable. Try again."), { status });
15
+ }
16
+ return { user: auth.user };
8
17
  }
9
18
  export function getRecentAuthBody(maxAge) {
10
19
  const auth = getAuthBody();
@@ -17,8 +26,8 @@ export async function getSignInUrlBody(options) {
17
26
  context.appendSetCookie(line);
18
27
  return url;
19
28
  }
20
- export async function signOutBody(everywhere = false) {
21
- const result = await endSession(everywhere);
29
+ export async function signOutBody(options = {}) {
30
+ const result = await endSession(options);
22
31
  if (result instanceof Response)
23
32
  throw result;
24
33
  throw redirect({ href: result, reloadDocument: true });
@@ -28,18 +28,21 @@ export function oidcMiddleware(setup) {
28
28
  core ??= createOidcCore(setup);
29
29
  }
30
30
  const call = callContext(context);
31
- const { cookies, session, setCookies } = await core.resolve(request, call);
32
- const pending = [...setCookies];
31
+ const { cookies, session, setCookies, status, reason } = await core.resolve(request, call);
32
+ let sessionUpdates = setCookies;
33
+ const appended = [];
33
34
  const oidc = {
34
35
  core,
35
36
  request,
36
37
  cookies,
37
38
  session,
38
39
  call,
39
- auth: () => ({ user: session?.user ?? null }),
40
- appendSetCookie: (line) => pending.push(line),
40
+ auth: () => ({ user: session?.user ?? null, status, reason }),
41
+ appendSetCookie: (line) => appended.push(line),
42
+ discardSessionUpdates: () => { sessionUpdates = []; },
41
43
  };
42
44
  const result = await next({ context: { oidc } });
45
+ const pending = [...sessionUpdates, ...appended];
43
46
  if (pending.length) {
44
47
  // Start must receive its original response to serialize RPC redirects.
45
48
  const { appendResponseHeader } = await import("@tanstack/react-start/server");
package/dist/routes.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SignOutOptions } from "@lpshanley/oidc-start-core/models";
1
2
  import type { CallbackRouteOptions, SignInRouteOptions } from "./routes.server.js";
2
3
  export type { CallbackRouteOptions, SignInRouteOptions };
3
4
  type RouteHandler = (args: {
@@ -10,4 +11,4 @@ export declare function handleCallbackRoute(options?: CallbackRouteOptions): Rou
10
11
  /** POST: the provider's back-channel logout; ends the sessions its token names. */
11
12
  export declare function handleBackchannelLogoutRoute(): RouteHandler;
12
13
  /** POST: clears the session and redirects to the provider logout. */
13
- export declare function handleSignOutRoute(): RouteHandler;
14
+ export declare function handleSignOutRoute(options?: SignOutOptions): RouteHandler;
package/dist/routes.js CHANGED
@@ -1,5 +1,3 @@
1
- // Server route handler factories, after WorkOS's handleCallbackRoute. They run
2
- // behind oidcMiddleware, which applies the cookies they queue.
3
1
  /** GET: redirects to the provider; `?returnPathname=` sets the destination. */
4
2
  export function handleSignInRoute(options = {}) {
5
3
  return async ({ request }) => {
@@ -22,9 +20,9 @@ export function handleBackchannelLogoutRoute() {
22
20
  };
23
21
  }
24
22
  /** POST: clears the session and redirects to the provider logout. */
25
- export function handleSignOutRoute() {
23
+ export function handleSignOutRoute(options = {}) {
26
24
  return async ({ request }) => {
27
25
  const { signOutRoute } = await import("./routes.server.js");
28
- return signOutRoute(request);
26
+ return signOutRoute(request, options);
29
27
  };
30
28
  }
@@ -1,5 +1,5 @@
1
1
  import type { CallbackErrorReason } from "@lpshanley/oidc-start-core";
2
- import type { SignInOptions } from "@lpshanley/oidc-start-core/models";
2
+ import type { SignInOptions, SignOutOptions } from "@lpshanley/oidc-start-core/models";
3
3
  type FailureOptions = {
4
4
  /** Where to send the browser when sign-in fails. Defaults to a 400. */
5
5
  errorRedirectUrl?: string;
@@ -16,6 +16,6 @@ export type CallbackRouteOptions = FailureOptions & {
16
16
  };
17
17
  export declare function signInRoute(request: Request, options: SignInRouteOptions): Promise<Response>;
18
18
  export declare function callbackRoute(request: Request, options: CallbackRouteOptions): Promise<Response>;
19
- export declare function signOutRoute(request: Request): Promise<Response>;
19
+ export declare function signOutRoute(request: Request, options?: SignOutOptions): Promise<Response>;
20
20
  export declare function backchannelLogoutRoute(request: Request): Promise<Response>;
21
21
  export {};
@@ -46,6 +46,8 @@ export async function signInRoute(request, options) {
46
46
  export async function callbackRoute(request, options) {
47
47
  const context = getOidcContext();
48
48
  const result = await context.core.callback(request, context.cookies, context.call);
49
+ if (result.ok)
50
+ context.discardSessionUpdates?.();
49
51
  for (const line of result.setCookies)
50
52
  context.appendSetCookie(line);
51
53
  if (result.ok)
@@ -56,27 +58,27 @@ export async function callbackRoute(request, options) {
56
58
  headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" },
57
59
  });
58
60
  }
59
- context.core.report({ operation: "callback", error: result.error, context: context.call });
61
+ context.core.report({ operation: "callback", reason: result.reason, error: result.error, context: context.call });
60
62
  if (options.onError)
61
63
  return options.onError({ reason: result.reason, request });
62
64
  return failure(request, options);
63
65
  }
64
- export async function signOutRoute(request) {
66
+ export async function signOutRoute(request, options = {}) {
65
67
  if (request.method !== "POST") {
66
68
  return new Response("Method Not Allowed", {
67
69
  status: 405,
68
- headers: { Allow: "POST" },
70
+ headers: { Allow: "POST", "Cache-Control": "no-store" },
69
71
  });
70
72
  }
71
73
  const form = await request.formData().catch(() => undefined);
72
- const result = await endSession(form?.get("everywhere") === "1");
74
+ const result = await endSession({ ...options, everywhere: options.everywhere || form?.get("everywhere") === "1" });
73
75
  return result instanceof Response ? result : redirectTo(result, 303);
74
76
  }
75
77
  export async function backchannelLogoutRoute(request) {
76
78
  if (request.method !== "POST") {
77
79
  return new Response("Method Not Allowed", {
78
80
  status: 405,
79
- headers: { Allow: "POST" },
81
+ headers: { Allow: "POST", "Cache-Control": "no-store" },
80
82
  });
81
83
  }
82
84
  const context = getOidcContext();
@@ -1 +1,2 @@
1
- export declare function endSession(everywhere?: boolean): Promise<string | Response>;
1
+ import type { SignOutOptions } from "@lpshanley/oidc-start-core/models";
2
+ export declare function endSession(options?: SignOutOptions): Promise<string | Response>;
@@ -1,9 +1,12 @@
1
1
  import { getOidcContext } from "./context.js";
2
- export async function endSession(everywhere = false) {
2
+ export async function endSession(options = {}) {
3
3
  const context = getOidcContext();
4
+ // A failed revocation must leave the incoming cookie available for retry.
5
+ // A successful sign-out supplies all deletions for that same incoming jar.
6
+ context.discardSessionUpdates?.();
4
7
  let result;
5
8
  try {
6
- result = await context.core.signOut(context.request, context.cookies, context.session, context.call, everywhere);
9
+ result = await context.core.signOut(context.request, context.cookies, context.session, context.call, options);
7
10
  }
8
11
  catch (error) {
9
12
  context.core.report({ operation: "end-session", error, context: context.call });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lpshanley/oidc-start-react",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "node": ">=22.12.0"
41
41
  },
42
42
  "dependencies": {
43
- "@lpshanley/oidc-start-core": "0.4.2",
43
+ "@lpshanley/oidc-start-core": "0.5.0",
44
44
  "zod": "4.6.7"
45
45
  },
46
46
  "devDependencies": {
@@ -66,7 +66,7 @@
66
66
  }
67
67
  },
68
68
  "scripts": {
69
- "build": "tsc -p tsconfig.build.json",
69
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
70
70
  "typecheck": "tsc --noEmit",
71
71
  "test": "vitest run",
72
72
  "check-package": "publint --strict"