@lpshanley/oidc-start-react 0.2.0 → 0.4.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,8 +1,8 @@
1
1
  # @lpshanley/oidc-start-react
2
2
 
3
- Add OpenID Connect sign-in to a TanStack Start app. Loaders can read the current user, components can start sign-in or sign-out, and Start’s middleware handles the session cookies. The code exchange and provider tokens stay on the server.
3
+ Add OpenID Connect sign-in to a TanStack Start app. Loaders can read the current user, components can start sign-in or sign-out, and Start’s middleware handles the session cookies. The server exchanges the authorization code. Loaders and React components receive the user profile.
4
4
 
5
- This guide adds two routes to an existing app. The home page at `/` shows who is signed in. The callback at `/callback` receives the browser when the provider sends it back. [Complete example app](https://github.com/lpshanley/oidc-start/tree/main/examples/tanstack-start)
5
+ The quickstart connects a root loader, a home page at `/` and a callback at `/callback`. The example also includes `/sign-in`, a guarded `/account` page and a `/settings` page that requires a recent login. [Complete example app](https://github.com/lpshanley/oidc-start/tree/main/examples/tanstack-start)
6
6
 
7
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
8
 
@@ -64,51 +64,158 @@ export const Route = createFileRoute('/callback')({
64
64
  })
65
65
  ```
66
66
 
67
- ## 4. Connect the buttons
67
+ ## 4. Load the user in the root route
68
68
 
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.
69
+ In `src/routes/__root.tsx`, call `getAuth()` from the loader. It returns `{ user }`, with `user: null` for a guest. Pass the result to `AuthProvider` so descendants share the user on the first render and after navigation. [Example root route](https://github.com/lpshanley/oidc-start/blob/main/examples/tanstack-start/src/routes/__root.tsx)
70
+
71
+ ```tsx
72
+ import { AuthProvider } from '@lpshanley/oidc-start-react/client'
73
+ import { getAuth } from '@lpshanley/oidc-start-react/functions'
74
+ import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'
75
+
76
+ export const Route = createRootRoute({ loader: () => getAuth(), component: Root })
77
+
78
+ function Root() {
79
+ const auth = Route.useLoaderData()
80
+ return (
81
+ <html lang="en">
82
+ <head><HeadContent /></head>
83
+ <body>
84
+ <AuthProvider auth={auth}><Outlet /></AuthProvider>
85
+ <Scripts />
86
+ </body>
87
+ </html>
88
+ )
89
+ }
90
+ ```
91
+
92
+ Upgrading from 0.3.x? Rename the `OidcAuthProvider` import and JSX tags to `AuthProvider`. It takes the same `auth` and `children` props.
93
+
94
+ ## 5. Connect the buttons
95
+
96
+ In `src/routes/index.tsx`, read the user and actions from `useAuth()`. The hook handles navigation to the provider for both sign-in and sign-out. [Example home page](https://github.com/lpshanley/oidc-start/blob/main/examples/tanstack-start/src/routes/index.tsx)
70
97
 
71
98
  ```tsx
72
99
  import { displayName } from '@lpshanley/oidc-start-react'
73
- import { getAuth, getSignInUrl, signOut } from '@lpshanley/oidc-start-react/functions'
100
+ import { useAuth } from '@lpshanley/oidc-start-react/client'
74
101
  import { createFileRoute } from '@tanstack/react-router'
75
- import { useServerFn } from '@tanstack/react-start'
76
102
 
77
- export const Route = createFileRoute('/')({
78
- loader: () => getAuth(),
79
- component: Home,
80
- })
103
+ export const Route = createFileRoute('/')({ component: Home })
81
104
 
82
105
  function Home() {
83
- const { user } = Route.useLoaderData()
84
- const signOutAndRedirect = useServerFn(signOut)
85
-
86
- if (!user) {
87
- return <button onClick={async () => location.assign(await getSignInUrl())}>Sign in</button>
88
- }
89
-
106
+ const { user, signIn, signOut } = useAuth()
107
+ if (!user) return <button onClick={() => signIn()}>Sign in</button>
90
108
  return (
91
109
  <main>
92
110
  <p>Signed in as {displayName(user)}.</p>
93
- <button onClick={() => signOutAndRedirect()}>Sign out</button>
111
+ <button onClick={signOut}>Sign out</button>
94
112
  </main>
95
113
  )
96
114
  }
97
115
  ```
98
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.
118
+
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
+
121
+ Import `useUser` from `@lpshanley/oidc-start-react/client` when a component only needs the loader's `AuthUser | null`.
122
+
123
+ For navigation links, import `SignedIn` and `SignedOut` from that same entry. Inside the provider, `<SignedIn><AccountMenu /></SignedIn>` shows the menu to a signed-in user and `<SignedOut><SignInButton /></SignedOut>` shows a button to a guest. Both use the loader's user during SSR and client navigation.
124
+
99
125
  **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
126
 
101
- To return someone to a specific page, pass a local path when starting sign-in, for example `getSignInUrl({ data: { returnPathname: '/account' } })`.
127
+ ### Recheck the session when a tab regains focus
128
+
129
+ To pick up a sign-out from another tab, add this effect to the root component that reads the auth loader. Invalidating the root match reruns `getAuth()` and supplies the new user to the provider. The server function returns only `{ user }` and sets `Cache-Control: no-store`.
130
+
131
+ ```tsx
132
+ import { rootRouteId, useRouter } from '@tanstack/react-router'
133
+ import { useEffect } from 'react'
134
+
135
+ function useRecheckAuthOnFocus() {
136
+ const router = useRouter()
137
+ useEffect(() => {
138
+ const recheck = () => {
139
+ void router.invalidate({ filter: match => match.routeId === rootRouteId })
140
+ }
141
+ window.addEventListener('focus', recheck)
142
+ return () => window.removeEventListener('focus', recheck)
143
+ }, [router])
144
+ }
145
+ ```
146
+
147
+ Call `useRecheckAuthOnFocus()` once in the root component to opt in. A session check can update the idle timestamp, so a periodic timer in an unattended tab can extend its session.
148
+
149
+ ### Sign in and return here
150
+
151
+ From a page with a sign-in button, pass its local pathname when the user clicks. The server sanitizes the path and binds it to this sign-in flow. Omitting the option keeps the default destination of `/`.
152
+
153
+ ```tsx
154
+ function SignInHere() {
155
+ const { signIn } = useAuth()
156
+ return <button onClick={() => signIn({ returnPathname: window.location.pathname })}>Sign in</button>
157
+ }
158
+ ```
159
+
160
+ If the page needs its current filters or anchor after sign-in, append `window.location.search` or `window.location.hash` to the pathname. From a dedicated login page, pass the intended destination instead. The server function accepts the same option, for example `getSignInUrl({ data: { returnPathname: '/account' } })`.
161
+
162
+ 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.
102
163
 
103
164
  ## HTTP routes and server access
104
165
 
105
- 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.
166
+ The buttons above call server functions through `useAuth()`. To call those functions directly, import `getSignInUrl` and `signOut` from `@lpshanley/oidc-start-react/functions`. Bind the latter with `useServerFn(signOut)` in a component so the router follows the sign-out redirect. For ordinary links and forms, mount these handlers from `@lpshanley/oidc-start-react` behind the same OIDC middleware. Choose the paths in your app.
106
167
 
107
168
  | Method | Handler | When to use it |
108
169
  | --- | --- | --- |
109
- | GET | `handleSignInRoute()` | Link to sign-in. The optional `returnPathname` query parameter selects a local return path. |
170
+ | GET | `handleSignInRoute()` | Link to sign-in. Use `returnPathname` for the local return path and `maxAge` to request a recent provider login. |
110
171
  | GET | `handleCallbackRoute()` | Mount at the exact callback path registered with your provider. |
111
- | POST | `handleSignOutRoute()` | Submit a same-origin form. Add `server.middleware: [createCsrfMiddleware()]` to this route because the global filter above only covers server functions. |
172
+ | POST | `handleSignOutRoute()` | Submit a same-origin form. Include the field `everywhere=1` to revoke all of the user's app sessions. Add `server.middleware: [createCsrfMiddleware()]` to this route because the global filter above only covers server functions. |
112
173
  | 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
174
 
114
175
  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.
176
+
177
+ ### Protect route navigation
178
+
179
+ Mount `GET: handleSignInRoute()` at a local path such as `/sign-in`. A protected route can then use the following option with `requireAuth` from `@lpshanley/oidc-start-react/functions`.
180
+
181
+ ```ts
182
+ beforeLoad: ({ location }) => requireAuth({ signInPath: '/sign-in', returnPathname: location.href })
183
+ ```
184
+
185
+ The helper reads the session on the server. A guest follows a document redirect to the sign-in route. A signed-in user is available as `Route.useRouteContext().user`. Enforce authorization in every server function or endpoint that serves private data, including those called by the guarded route.
186
+
187
+ ### Customize callback failures
188
+
189
+ 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.
190
+
191
+ To show a message on your own login page, redirect the classified reason to a fixed local route.
192
+
193
+ ```ts
194
+ GET: handleCallbackRoute({
195
+ onError: ({ reason }) => new Response(null, {
196
+ status: 303,
197
+ headers: {
198
+ Location: '/login?' + new URLSearchParams({ auth_error: reason }),
199
+ 'Cache-Control': 'no-store',
200
+ },
201
+ }),
202
+ })
203
+ ```
204
+
205
+ On that page, import `authErrorMessage` from `@lpshanley/oidc-start-react`. Read `auth_error` from the route's search parameters and render `<p role="alert">{authErrorMessage(auth_error)}</p>` when the parameter is present. The helper selects fixed messages and gives unknown values a generic retry message. Host access denials keep their 403 response.
206
+
207
+ ### Require a recent login
208
+
209
+ Use `requireRecentAuth` from `@lpshanley/oidc-start-react/functions` in `beforeLoad` for a page that requires a recent provider login.
210
+
211
+ ```ts
212
+ beforeLoad: ({ location }) => requireRecentAuth({
213
+ maxAge: 300, signInPath: '/sign-in', returnPathname: location.href,
214
+ })
215
+ ```
216
+
217
+ A guest or stale session is sent to the local sign-in endpoint with the same `maxAge` in seconds. A `maxAge` configured on `handleSignInRoute` caps the value the URL can request. Choose a positive window that gives the user time to return from the provider.
218
+
219
+ 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.
220
+
221
+ 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,2 @@
1
+ /** Safe display text for a callback reason or an untrusted auth_error query value. */
2
+ export declare function authErrorMessage(reason: unknown): string;
@@ -0,0 +1,17 @@
1
+ const retry = "Sign-in could not be completed. Please try again.";
2
+ const messages = {
3
+ access_denied: "Sign-in was declined. You can try again.",
4
+ sign_in_expired: "Your sign-in request expired. Please start again.",
5
+ invalid_callback: retry,
6
+ invalid_state: retry,
7
+ provider_error: "Your identity provider could not complete sign-in. Please try again.",
8
+ token_exchange_failed: retry,
9
+ session_failed: retry,
10
+ };
11
+ /** Safe display text for a callback reason or an untrusted auth_error query value. */
12
+ export function authErrorMessage(reason) {
13
+ if (typeof reason === "string" && Object.hasOwn(messages, reason)) {
14
+ return messages[reason];
15
+ }
16
+ return retry;
17
+ }
@@ -0,0 +1,48 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import { type AuthSession, type SignInOptions } from "@lpshanley/oidc-start-core/models";
3
+ /** Supply the root loader's getAuth() result for SSR and client navigation. */
4
+ export declare function AuthProvider({ 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
+ /** The user supplied by the current root loader, or null when signed out. */
17
+ export declare function useUser(): {
18
+ sub: string;
19
+ email: string;
20
+ name: string | null;
21
+ picture?: string | null | undefined;
22
+ roles?: string[] | undefined;
23
+ } | null;
24
+ /** Render children when the loader has a signed-in user. */
25
+ export declare function SignedIn({ children }: {
26
+ children: ReactNode;
27
+ }): ReactElement | null;
28
+ /** Render children when the loader has no signed-in user. */
29
+ export declare function SignedOut({ children }: {
30
+ children: ReactNode;
31
+ }): ReactElement | null;
32
+ export declare function useAuth(): {
33
+ user: {
34
+ sub: string;
35
+ email: string;
36
+ name: string | null;
37
+ picture?: string | null | undefined;
38
+ roles?: string[] | undefined;
39
+ } | null;
40
+ hasRole: (role: string) => boolean;
41
+ signIn(options?: SignInOptions): Promise<void>;
42
+ signOut: {
43
+ (): Promise<never>;
44
+ (options?: {
45
+ everywhere?: boolean;
46
+ }): Promise<never>;
47
+ };
48
+ };
package/dist/client.js ADDED
@@ -0,0 +1,39 @@
1
+ import { createContext, createElement, Fragment, useContext } from "react";
2
+ import { useServerFn } from "@tanstack/react-start";
3
+ import { hasRole } from "@lpshanley/oidc-start-core/models";
4
+ import { getSignInUrl, signOut } from "./functions.js";
5
+ const AuthContext = createContext(undefined);
6
+ /** Supply the root loader's getAuth() result for SSR and client navigation. */
7
+ export function AuthProvider({ auth, children }) {
8
+ return createElement(AuthContext.Provider, { value: auth }, children);
9
+ }
10
+ /** The user supplied by the current root loader, or null when signed out. */
11
+ export function useUser() {
12
+ const auth = useContext(AuthContext);
13
+ if (!auth)
14
+ throw new Error("Wrap components that use auth hooks in <AuthProvider>.");
15
+ return auth.user;
16
+ }
17
+ /** Render children when the loader has a signed-in user. */
18
+ export function SignedIn({ children }) {
19
+ return useUser() ? createElement(Fragment, null, children) : null;
20
+ }
21
+ /** Render children when the loader has no signed-in user. */
22
+ export function SignedOut({ children }) {
23
+ return useUser() ? null : createElement(Fragment, null, children);
24
+ }
25
+ export function useAuth() {
26
+ const user = useUser();
27
+ const signOutAndRedirect = useServerFn(signOut);
28
+ function signOutUser(options) {
29
+ return signOutAndRedirect({ data: { everywhere: options?.everywhere } });
30
+ }
31
+ return {
32
+ user,
33
+ hasRole: (role) => user !== null && hasRole(user, role),
34
+ async signIn(options) {
35
+ window.location.assign(await getSignInUrl({ data: options }));
36
+ },
37
+ signOut: signOutUser,
38
+ };
39
+ }
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
+ }
@@ -9,9 +9,48 @@ export declare const getAuth: import("@tanstack/react-start").OptionalFetcher<un
9
9
  roles?: string[] | undefined;
10
10
  } | null;
11
11
  }>>;
12
+ type GuardOptions = {
13
+ /** App-local GET endpoint mounted with handleSignInRoute(). */
14
+ signInPath: string;
15
+ /** App-local destination to resume after the callback. */
16
+ returnPathname: string;
17
+ };
18
+ /** Guard route navigation and supply a signed-in user to descendant routes. */
19
+ export declare function requireAuth(options: GuardOptions): Promise<{
20
+ user: {
21
+ sub: string;
22
+ email: string;
23
+ name: string | null;
24
+ picture?: string | null | undefined;
25
+ roles?: string[] | undefined;
26
+ };
27
+ }>;
28
+ /** Guard navigation using the provider login time. Recheck inside sensitive actions. */
29
+ export declare function requireRecentAuth(options: GuardOptions & {
30
+ maxAge: number;
31
+ }): Promise<{
32
+ user: {
33
+ sub: string;
34
+ email: string;
35
+ name: string | null;
36
+ picture?: string | null | undefined;
37
+ roles?: string[] | undefined;
38
+ };
39
+ }>;
12
40
  /** An authorization URL; also queues the flow's verifier cookie. */
13
41
  export declare const getSignInUrl: import("@tanstack/react-start").OptionalFetcher<undefined, z.ZodOptional<z.ZodObject<{
14
42
  returnPathname: z.ZodOptional<z.ZodString>;
43
+ maxAge: z.ZodOptional<z.ZodNumber>;
44
+ 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
+ }>>;
15
51
  }, z.core.$strip>>, Promise<string>>;
16
52
  /** Clears the session and redirects to provider logout. In a component, call via useServerFn. */
17
- export declare const signOut: import("@tanstack/react-start").OptionalFetcher<undefined, undefined, Promise<never>>;
53
+ export declare const signOut: import("@tanstack/react-start").OptionalFetcher<undefined, z.ZodOptional<z.ZodObject<{
54
+ everywhere: z.ZodOptional<z.ZodBoolean>;
55
+ }, z.core.$strip>>, Promise<never>>;
56
+ export {};
package/dist/functions.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
+ import { redirect } from "@tanstack/react-router";
2
3
  import { z } from "zod";
4
+ import { sanitizeReturnPath, signInOptionsSchema } from "@lpshanley/oidc-start-core/models";
3
5
  // Server functions after WorkOS's server-functions.ts; bodies load lazily so
4
6
  // server code stays out of the client bundle.
5
7
  /** The signed-in user, or null. Never returns tokens. */
@@ -7,15 +9,44 @@ export const getAuth = createServerFn({ method: "GET" }).handler(async () => {
7
9
  const { getAuthBody } = await import("./functions.server.js");
8
10
  return getAuthBody();
9
11
  });
12
+ function redirectToSignIn({ signInPath, returnPathname }, maxAge) {
13
+ const url = new URL(sanitizeReturnPath(signInPath), "https://sign-in.invalid");
14
+ url.searchParams.set("returnPathname", sanitizeReturnPath(returnPathname));
15
+ if (maxAge !== undefined)
16
+ url.searchParams.set("maxAge", String(maxAge));
17
+ throw redirect({ href: url.pathname + url.search + url.hash, reloadDocument: true });
18
+ }
19
+ /** Guard route navigation and supply a signed-in user to descendant routes. */
20
+ export async function requireAuth(options) {
21
+ const { user } = await getAuth();
22
+ if (!user)
23
+ redirectToSignIn(options);
24
+ return { user };
25
+ }
26
+ const getRecentAuth = createServerFn({ method: "GET" })
27
+ .validator(z.object({ maxAge: z.int().nonnegative() }))
28
+ .handler(async ({ data }) => {
29
+ const { getRecentAuthBody } = await import("./functions.server.js");
30
+ return getRecentAuthBody(data.maxAge);
31
+ });
32
+ /** Guard navigation using the provider login time. Recheck inside sensitive actions. */
33
+ export async function requireRecentAuth(options) {
34
+ const { user } = await getRecentAuth({ data: { maxAge: options.maxAge } });
35
+ if (!user)
36
+ redirectToSignIn(options, options.maxAge);
37
+ return { user };
38
+ }
10
39
  /** An authorization URL; also queues the flow's verifier cookie. */
11
40
  export const getSignInUrl = createServerFn({ method: "GET" })
12
- .validator(z.object({ returnPathname: z.string().optional() }).optional())
41
+ .validator(signInOptionsSchema.optional())
13
42
  .handler(async ({ data }) => {
14
43
  const { getSignInUrlBody } = await import("./functions.server.js");
15
- return getSignInUrlBody(data?.returnPathname);
44
+ return getSignInUrlBody(data);
16
45
  });
17
46
  /** Clears the session and redirects to provider logout. In a component, call via useServerFn. */
18
- export const signOut = createServerFn({ method: "POST" }).handler(async () => {
47
+ export const signOut = createServerFn({ method: "POST" })
48
+ .validator(z.object({ everywhere: z.boolean().optional() }).optional())
49
+ .handler(async ({ data }) => {
19
50
  const { signOutBody } = await import("./functions.server.js");
20
- return signOutBody();
51
+ return signOutBody(data?.everywhere);
21
52
  });
@@ -1,4 +1,5 @@
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>;
4
- export declare function signOutBody(): Promise<never>;
3
+ export declare function getRecentAuthBody(maxAge: number): AuthSession;
4
+ export declare function getSignInUrlBody(options?: SignInOptions): Promise<string>;
5
+ export declare function signOutBody(everywhere?: boolean): Promise<never>;
@@ -1,20 +1,24 @@
1
1
  import { redirect } from "@tanstack/react-router";
2
2
  import { setResponseHeader } from "@tanstack/react-start/server";
3
3
  import { endSession } from "./sign-out.server.js";
4
- import { getOidcContext } from "./context.js";
4
+ import { checkRecentAuth, getOidcContext } from "./context.js";
5
5
  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 function getRecentAuthBody(maxAge) {
10
+ const auth = getAuthBody();
11
+ return checkRecentAuth({ maxAge }).isStale ? { user: null } : auth;
12
+ }
13
+ export async function getSignInUrlBody(options) {
10
14
  const context = getOidcContext();
11
- const { url, setCookies } = await context.core.signIn(context.request, context.cookies, returnPathname, context.call);
15
+ const { url, setCookies } = await context.core.signIn(context.request, context.cookies, options, context.call);
12
16
  for (const line of setCookies)
13
17
  context.appendSetCookie(line);
14
18
  return url;
15
19
  }
16
- export async function signOutBody() {
17
- const result = await endSession();
20
+ export async function signOutBody(everywhere = false) {
21
+ const result = await endSession(everywhere);
18
22
  if (result instanceof Response)
19
23
  throw result;
20
24
  throw redirect({ href: result, reloadDocument: true });
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export * from "@lpshanley/oidc-start-core/models";
2
2
  export type { OidcCallContext, OidcErrorEvent, OidcMiddlewareOptions, } from "@lpshanley/oidc-start-core/config";
3
3
  export { type OidcMiddlewareSetup, oidcMiddleware } from "./middleware.js";
4
- 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";
6
+ export { authErrorMessage } from "./auth-error.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "@lpshanley/oidc-start-core/models";
2
2
  export { oidcMiddleware } from "./middleware.js";
3
3
  export { handleBackchannelLogoutRoute, handleCallbackRoute, handleSignInRoute, handleSignOutRoute, } from "./routes.js";
4
+ export { authErrorMessage } from "./auth-error.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,29 @@ 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 query = new URL(request.url).searchParams;
22
+ const requestedAge = query.get("maxAge");
23
+ let maxAge = options.maxAge;
24
+ if (requestedAge !== null) {
25
+ const age = Number(requestedAge);
26
+ if (!/^\d+$/.test(requestedAge) || !Number.isSafeInteger(age)) {
27
+ return new Response("Invalid maxAge.", { status: 400, headers: { "Cache-Control": "no-store" } });
28
+ }
29
+ maxAge = Math.min(maxAge ?? age, age);
30
+ }
31
+ const { url, setCookies } = await context.core.signIn(request, context.cookies, {
32
+ returnPathname: query.get("returnPathname") ?? options.returnPathname,
33
+ loginHint: options.loginHint,
34
+ maxAge,
35
+ prompt: options.prompt,
36
+ }, context.call);
26
37
  for (const line of setCookies)
27
38
  context.appendSetCookie(line);
28
39
  return redirectTo(url, 307);
29
40
  }
30
41
  catch (error) {
31
- return failure(request, options, "sign-in", error);
42
+ context.core.report({ operation: "sign-in", error, context: context.call });
43
+ return failure(request, options);
32
44
  }
33
45
  }
34
46
  export async function callbackRoute(request, options) {
@@ -38,13 +50,16 @@ export async function callbackRoute(request, options) {
38
50
  context.appendSetCookie(line);
39
51
  if (result.ok)
40
52
  return redirectTo(result.location, 307);
41
- if (result.error instanceof SessionAccessDeniedError) {
53
+ if (result.reason === "session_access_denied") {
42
54
  return new Response(options.accessDeniedMessage ?? "Your account does not have access.", {
43
55
  status: 403,
44
56
  headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" },
45
57
  });
46
58
  }
47
- return failure(request, options, "callback", result.error);
59
+ context.core.report({ operation: "callback", error: result.error, context: context.call });
60
+ if (options.onError)
61
+ return options.onError({ reason: result.reason, request });
62
+ return failure(request, options);
48
63
  }
49
64
  export async function signOutRoute(request) {
50
65
  if (request.method !== "POST") {
package/dist/server.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export * from "@lpshanley/oidc-start-core";
2
- export { getOidcContext, type OidcContext } from "./context.js";
2
+ export { checkRecentAuth, getOidcContext, type OidcContext } from "./context.js";
package/dist/server.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export * from "@lpshanley/oidc-start-core";
2
- export { getOidcContext } from "./context.js";
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.2.0",
3
+ "version": "0.4.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.2.0",
43
+ "@lpshanley/oidc-start-core": "0.4.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",