@lpshanley/oidc-start-react 0.3.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 +116 -23
- package/dist/auth-error.d.ts +2 -0
- package/dist/auth-error.js +17 -0
- package/dist/client.d.ts +26 -4
- package/dist/client.js +25 -7
- package/dist/functions.d.ts +39 -7
- package/dist/functions.js +34 -3
- package/dist/functions.server.d.ts +2 -1
- package/dist/functions.server.js +7 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/routes.server.js +12 -2
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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,158 @@ export const Route = createFileRoute('/callback')({
|
|
|
64
64
|
})
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
## 4.
|
|
67
|
+
## 4. Load the user in the root route
|
|
68
68
|
|
|
69
|
-
In `src/routes/
|
|
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 {
|
|
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 } =
|
|
84
|
-
|
|
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={
|
|
111
|
+
<button onClick={signOut}>Sign out</button>
|
|
94
112
|
</main>
|
|
95
113
|
)
|
|
96
114
|
}
|
|
97
115
|
```
|
|
98
116
|
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
+
|
|
120
187
|
### Customize callback failures
|
|
121
188
|
|
|
122
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.
|
|
123
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
|
+
|
|
124
207
|
### Require a recent login
|
|
125
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
|
+
|
|
126
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.
|
|
127
220
|
|
|
128
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,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
|
|
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
|
|
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:
|
|
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
|
|
7
|
+
export function AuthProvider({ auth, children }) {
|
|
7
8
|
return createElement(AuthContext.Provider, { value: auth }, children);
|
|
8
9
|
}
|
|
9
|
-
|
|
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("
|
|
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
|
|
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:
|
|
37
|
+
signOut: signOutUser,
|
|
20
38
|
};
|
|
21
39
|
}
|
package/dist/functions.d.ts
CHANGED
|
@@ -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,
|
|
13
|
-
returnPathname:
|
|
14
|
-
maxAge:
|
|
15
|
-
loginHint:
|
|
16
|
-
prompt:
|
|
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
|
-
},
|
|
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,
|
|
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 {
|
|
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" })
|
|
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>;
|
package/dist/functions.server.js
CHANGED
|
@@ -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";
|
package/dist/routes.server.js
CHANGED
|
@@ -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:
|
|
32
|
+
returnPathname: query.get("returnPathname") ?? options.returnPathname,
|
|
23
33
|
loginHint: options.loginHint,
|
|
24
|
-
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
|
+
"version": "0.4.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.
|
|
43
|
+
"@lpshanley/oidc-start-core": "0.4.0",
|
|
44
44
|
"zod": "4.6.7"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|