@lpshanley/oidc-start-react 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,65 +64,194 @@ 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
 
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)
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.
100
124
 
101
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.
102
126
 
103
- 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' } })`.
104
161
 
105
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.
106
163
 
107
164
  ## HTTP routes and server access
108
165
 
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.
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.
110
167
 
111
168
  | Method | Handler | When to use it |
112
169
  | --- | --- | --- |
113
- | 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. |
114
171
  | GET | `handleCallbackRoute()` | Mount at the exact callback path registered with your provider. |
115
- | 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. |
116
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. |
117
174
 
118
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.
119
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
+ ### Restrict an admin route by role
188
+
189
+ Import `requireAuth` from `@lpshanley/oidc-start-react/functions`, `hasRole` from the package root, and `redirect` from `@tanstack/react-router`. Compose them in the admin route's `beforeLoad`.
190
+
191
+ ```ts
192
+ beforeLoad: async ({ location }) => {
193
+ const { user } = await requireAuth({ signInPath: '/sign-in', returnPathname: location.href })
194
+ if (!hasRole(user, 'admin')) throw redirect({ href: '/forbidden', reloadDocument: true })
195
+ return { user }
196
+ }
197
+ ```
198
+
199
+ Guests sign in and return to the requested URL. An admin sees the dashboard. A signed-in user without the role goes to the app's `/forbidden` endpoint, which returns HTTP 403. Use role names from your provider's configured roles claim. [Admin route](https://github.com/lpshanley/oidc-start/blob/main/examples/tanstack-start/src/routes/admin.tsx) · [Forbidden response](https://github.com/lpshanley/oidc-start/blob/main/examples/tanstack-start/src/routes/forbidden.ts)
200
+
201
+ Private endpoints must also check the current request before reading admin data or performing a mutation. In a server route handler, import `getOidcContext` from `@lpshanley/oidc-start-react/server` and check the resolved user.
202
+
203
+ ```ts
204
+ const { user } = getOidcContext().auth()
205
+ if (!user) return new Response(null, { status: 401 })
206
+ if (!hasRole(user, 'admin')) return new Response(null, { status: 403 })
207
+ // Perform the authorized operation here.
208
+ ```
209
+
120
210
  ### Customize callback failures
121
211
 
122
212
  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
213
 
214
+ To show a message on your own login page, redirect the classified reason to a fixed local route.
215
+
216
+ ```ts
217
+ GET: handleCallbackRoute({
218
+ onError: ({ reason }) => new Response(null, {
219
+ status: 303,
220
+ headers: {
221
+ Location: '/login?' + new URLSearchParams({ auth_error: reason }),
222
+ 'Cache-Control': 'no-store',
223
+ },
224
+ }),
225
+ })
226
+ ```
227
+
228
+ On the login page, read `auth_error` with `Route.useSearch()`. When the parameter is present, render `<p role="alert">{authErrorMessage(auth_error)}</p>` using `authErrorMessage` from `@lpshanley/oidc-start-react`. Unknown values receive a generic retry message. Host access denials keep their 403 response.
229
+
230
+ Keep the message visible until the user chooses **Dismiss**. Inside the login component, bind `const navigate = Route.useNavigate()` and use this click handler. It removes only the error parameter, preserves the fragment and replaces the current history entry so Back returns to the preceding page.
231
+
232
+ ```tsx
233
+ <button onClick={() => navigate({
234
+ search: previous => ({ ...previous, auth_error: undefined }),
235
+ hash: true,
236
+ replace: true,
237
+ resetScroll: false,
238
+ })}>Dismiss</button>
239
+ ```
240
+
241
+ The working example wires the callback to the login page and includes the route's search validation. [Callback](https://github.com/lpshanley/oidc-start/blob/main/examples/tanstack-start/src/routes/callback.ts) · [Dismissible login error](https://github.com/lpshanley/oidc-start/blob/main/examples/tanstack-start/src/routes/login.tsx)
242
+
124
243
  ### Require a recent login
125
244
 
245
+ Use `requireRecentAuth` from `@lpshanley/oidc-start-react/functions` in `beforeLoad` for a page that requires a recent provider login.
246
+
247
+ ```ts
248
+ beforeLoad: ({ location }) => requireRecentAuth({
249
+ maxAge: 300, signInPath: '/sign-in', returnPathname: location.href,
250
+ })
251
+ ```
252
+
253
+ 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.
254
+
126
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.
127
256
 
128
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.
@@ -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
+ }
package/dist/client.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { type ReactNode } from "react";
2
- import type { AuthSession, SignInOptions } from "@lpshanley/oidc-start-core/models";
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import { type AuthSession, type SignInOptions } from "@lpshanley/oidc-start-core/models";
3
3
  /** Supply the root loader's getAuth() result for SSR and client navigation. */
4
- export declare function OidcAuthProvider({ auth, children }: {
4
+ export declare function AuthProvider({ auth, children }: {
5
5
  auth: AuthSession;
6
6
  children: ReactNode;
7
7
  }): import("react").FunctionComponentElement<import("react").ProviderProps<{
@@ -13,6 +13,22 @@ export declare function OidcAuthProvider({ auth, children }: {
13
13
  roles?: string[] | undefined;
14
14
  } | null;
15
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;
16
32
  export declare function useAuth(): {
17
33
  user: {
18
34
  sub: string;
@@ -21,6 +37,12 @@ export declare function useAuth(): {
21
37
  picture?: string | null | undefined;
22
38
  roles?: string[] | undefined;
23
39
  } | null;
40
+ hasRole: (role: string) => boolean;
24
41
  signIn(options?: SignInOptions): Promise<void>;
25
- signOut: () => Promise<never>;
42
+ signOut: {
43
+ (): Promise<never>;
44
+ (options?: {
45
+ everywhere?: boolean;
46
+ }): Promise<never>;
47
+ };
26
48
  };
package/dist/client.js CHANGED
@@ -1,21 +1,39 @@
1
- import { createContext, createElement, useContext } from "react";
1
+ import { createContext, createElement, Fragment, useContext } from "react";
2
2
  import { useServerFn } from "@tanstack/react-start";
3
+ import { hasRole } from "@lpshanley/oidc-start-core/models";
3
4
  import { getSignInUrl, signOut } from "./functions.js";
4
5
  const AuthContext = createContext(undefined);
5
6
  /** Supply the root loader's getAuth() result for SSR and client navigation. */
6
- export function OidcAuthProvider({ auth, children }) {
7
+ export function AuthProvider({ auth, children }) {
7
8
  return createElement(AuthContext.Provider, { value: auth }, children);
8
9
  }
9
- export function useAuth() {
10
+ /** The user supplied by the current root loader, or null when signed out. */
11
+ export function useUser() {
10
12
  const auth = useContext(AuthContext);
11
- const signOutAndRedirect = useServerFn(signOut);
12
13
  if (!auth)
13
- throw new Error("useAuth() requires an OidcAuthProvider.");
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
+ }
14
31
  return {
15
- user: auth.user,
32
+ user,
33
+ hasRole: (role) => user !== null && hasRole(user, role),
16
34
  async signIn(options) {
17
35
  window.location.assign(await getSignInUrl({ data: options }));
18
36
  },
19
- signOut: () => signOutAndRedirect(),
37
+ signOut: signOutUser,
20
38
  };
21
39
  }
@@ -1,3 +1,4 @@
1
+ import { z } from "zod";
1
2
  /** The signed-in user, or null. Never returns tokens. */
2
3
  export declare const getAuth: import("@tanstack/react-start").OptionalFetcher<undefined, undefined, Promise<{
3
4
  user: {
@@ -8,17 +9,48 @@ export declare const getAuth: import("@tanstack/react-start").OptionalFetcher<un
8
9
  roles?: string[] | undefined;
9
10
  } | null;
10
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
+ }>;
11
40
  /** An authorization URL; also queues the flow's verifier cookie. */
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<{
41
+ export declare const getSignInUrl: import("@tanstack/react-start").OptionalFetcher<undefined, z.ZodOptional<z.ZodObject<{
42
+ returnPathname: z.ZodOptional<z.ZodString>;
43
+ maxAge: z.ZodOptional<z.ZodNumber>;
44
+ loginHint: z.ZodOptional<z.ZodString>;
45
+ prompt: z.ZodOptional<z.ZodEnum<{
17
46
  none: "none";
18
47
  login: "login";
19
48
  consent: "consent";
20
49
  select_account: "select_account";
21
50
  }>>;
22
- }, import("zod/v4/core").$strip>>, Promise<string>>;
51
+ }, z.core.$strip>>, Promise<string>>;
23
52
  /** Clears the session and redirects to provider logout. In a component, call via useServerFn. */
24
- 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 { signInOptionsSchema } from "@lpshanley/oidc-start-core/models";
2
+ import { redirect } from "@tanstack/react-router";
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,6 +9,33 @@ 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
41
  .validator(signInOptionsSchema.optional())
@@ -15,7 +44,9 @@ export const getSignInUrl = createServerFn({ method: "GET" })
15
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
1
  import type { AuthSession, SignInOptions } from "@lpshanley/oidc-start-core/models";
2
2
  export declare function getAuthBody(): AuthSession;
3
+ export declare function getRecentAuthBody(maxAge: number): AuthSession;
3
4
  export declare function getSignInUrlBody(options?: SignInOptions): Promise<string>;
4
- export declare function signOutBody(): Promise<never>;
5
+ export declare function signOutBody(everywhere?: boolean): Promise<never>;
@@ -1,11 +1,15 @@
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 function getRecentAuthBody(maxAge) {
10
+ const auth = getAuthBody();
11
+ return checkRecentAuth({ maxAge }).isStale ? { user: null } : auth;
12
+ }
9
13
  export async function getSignInUrlBody(options) {
10
14
  const context = getOidcContext();
11
15
  const { url, setCookies } = await context.core.signIn(context.request, context.cookies, options, context.call);
@@ -13,8 +17,8 @@ export async function getSignInUrlBody(options) {
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
@@ -3,3 +3,4 @@ export type { OidcCallContext, OidcErrorEvent, OidcMiddlewareOptions, } from "@l
3
3
  export { type OidcMiddlewareSetup, oidcMiddleware } from "./middleware.js";
4
4
  export { type CallbackRouteOptions, type SignInRouteOptions, handleBackchannelLogoutRoute, handleCallbackRoute, handleSignInRoute, handleSignOutRoute, } from "./routes.js";
5
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";
@@ -18,10 +18,20 @@ function failure(request, options) {
18
18
  export async function signInRoute(request, options) {
19
19
  const context = getOidcContext();
20
20
  try {
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
+ }
21
31
  const { url, setCookies } = await context.core.signIn(request, context.cookies, {
22
- returnPathname: new URL(request.url).searchParams.get("returnPathname") ?? options.returnPathname,
32
+ returnPathname: query.get("returnPathname") ?? options.returnPathname,
23
33
  loginHint: options.loginHint,
24
- maxAge: options.maxAge,
34
+ maxAge,
25
35
  prompt: options.prompt,
26
36
  }, context.call);
27
37
  for (const line of setCookies)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lpshanley/oidc-start-react",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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.3.0",
43
+ "@lpshanley/oidc-start-core": "0.4.1",
44
44
  "zod": "4.6.7"
45
45
  },
46
46
  "devDependencies": {