remix 3.0.0-beta.0 → 3.0.0-beta.2
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/dist/fetch-router.d.ts +7 -0
- package/dist/fetch-router.d.ts.map +1 -1
- package/dist/node-tsx/load-module.d.ts +2 -0
- package/dist/node-tsx/load-module.d.ts.map +1 -0
- package/dist/node-tsx/load-module.js +2 -0
- package/dist/node-tsx.d.ts +3 -0
- package/dist/node-tsx.d.ts.map +1 -0
- package/{src/node-serve.ts → dist/node-tsx.js} +2 -1
- package/dist/render-middleware.d.ts +2 -0
- package/dist/render-middleware.d.ts.map +1 -0
- package/dist/render-middleware.js +2 -0
- package/dist/route-pattern/href.d.ts +2 -0
- package/dist/route-pattern/href.d.ts.map +1 -0
- package/dist/route-pattern/href.js +2 -0
- package/dist/route-pattern/join.d.ts +2 -0
- package/dist/route-pattern/join.d.ts.map +1 -0
- package/dist/route-pattern/join.js +2 -0
- package/dist/route-pattern/match.d.ts +2 -0
- package/dist/route-pattern/match.d.ts.map +1 -0
- package/dist/route-pattern/match.js +2 -0
- package/package.json +158 -44
- package/src/assert/README.md +109 -0
- package/src/assets/README.md +539 -0
- package/src/async-context-middleware/README.md +100 -0
- package/src/auth/README.md +445 -0
- package/src/auth-middleware/README.md +246 -0
- package/src/cli/README.md +78 -0
- package/src/compression-middleware/README.md +176 -0
- package/src/cookie/README.md +106 -0
- package/src/cop-middleware/README.md +117 -0
- package/src/cors-middleware/README.md +174 -0
- package/src/csrf-middleware/README.md +99 -0
- package/src/data-schema/README.md +422 -0
- package/src/data-table/README.md +552 -0
- package/src/data-table-mysql/README.md +97 -0
- package/src/data-table-postgres/README.md +74 -0
- package/src/data-table-sqlite/README.md +84 -0
- package/src/fetch-proxy/README.md +46 -0
- package/src/fetch-router/README.md +902 -0
- package/src/fetch-router.ts +7 -0
- package/src/file-storage/README.md +57 -0
- package/src/file-storage-s3/README.md +47 -0
- package/src/form-data-middleware/README.md +109 -0
- package/src/form-data-parser/README.md +160 -0
- package/src/fs/README.md +60 -0
- package/src/headers/README.md +629 -0
- package/src/html-template/README.md +101 -0
- package/src/lazy-file/README.md +109 -0
- package/src/logger-middleware/README.md +132 -0
- package/src/method-override-middleware/README.md +71 -0
- package/src/mime/README.md +110 -0
- package/src/multipart-parser/README.md +241 -0
- package/src/node-fetch-server/README.md +352 -0
- package/src/node-tsx/README.md +79 -0
- package/src/node-tsx/load-module.ts +2 -0
- package/{dist/node-serve.js → src/node-tsx.ts} +2 -1
- package/src/render-middleware/README.md +99 -0
- package/src/render-middleware.ts +2 -0
- package/src/route-pattern/README.md +291 -0
- package/src/route-pattern/href.ts +2 -0
- package/src/route-pattern/join.ts +2 -0
- package/src/route-pattern/match.ts +2 -0
- package/src/session/README.md +171 -0
- package/src/session-middleware/README.md +109 -0
- package/src/session-storage-memcache/README.md +37 -0
- package/src/session-storage-redis/README.md +37 -0
- package/src/static-middleware/README.md +89 -0
- package/src/tar-parser/README.md +74 -0
- package/src/terminal/README.md +92 -0
- package/src/test/README.md +430 -0
- package/src/ui/README.md +219 -0
- package/src/ui/accordion/README.md +166 -0
- package/src/ui/anchor/README.md +153 -0
- package/src/ui/animation/README.md +316 -0
- package/src/ui/breadcrumbs/README.md +55 -0
- package/src/ui/button/README.md +44 -0
- package/src/ui/combobox/README.md +145 -0
- package/src/ui/glyph/README.md +72 -0
- package/src/ui/listbox/README.md +115 -0
- package/src/ui/menu/README.md +96 -0
- package/src/ui/popover/README.md +122 -0
- package/src/ui/scroll-lock/README.md +33 -0
- package/src/ui/select/README.md +107 -0
- package/src/ui/server/README.md +90 -0
- package/src/ui/test/README.md +107 -0
- package/src/ui/theme/README.md +103 -0
- package/dist/node-serve.d.ts +0 -2
- package/dist/node-serve.d.ts.map +0 -1
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
# auth
|
|
2
|
+
|
|
3
|
+
Composable browser authentication primitives for Remix. Use this package to verify credentials on your own server, start external OAuth or OIDC redirects, finish provider callbacks, and write an app-owned auth record into the session. Pair it with [`remix/middleware/auth`](https://github.com/remix-run/remix/tree/main/packages/auth-middleware) when later requests need to resolve that session data into the current user and protect routes.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Small, composable primitives: `verifyCredentials()`, `startExternalAuth()`, `finishExternalAuth()`, `refreshExternalAuth()`, and `completeAuth()`
|
|
8
|
+
- Built-in provider support for Google, Microsoft, Okta, Auth0, GitHub, Facebook, X, and Atmosphere
|
|
9
|
+
- Module-scope provider configuration for boot-time validation and stable callback URLs
|
|
10
|
+
- App-owned session records so you decide what auth data to persist
|
|
11
|
+
- Shared session completion for credentials and external auth flows
|
|
12
|
+
- Designed to pair with `remix/middleware/auth` for request-time auth resolution and route protection
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm i remix
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
`remix/auth` exposes five primitives:
|
|
23
|
+
|
|
24
|
+
- `verifyCredentials(provider, context)` parses submitted credentials and returns the authenticated result or `null`
|
|
25
|
+
- `startExternalAuth(provider, context, options?)` stores the in-progress OAuth transaction in the session and returns the provider redirect response
|
|
26
|
+
- `finishExternalAuth(provider, context, options?)` validates the callback, clears the stored transaction, and returns `{ result, returnTo? }`, including any provider tokens in `result.tokens`
|
|
27
|
+
- `refreshExternalAuth(provider, tokens)` exchanges a previously stored `refreshToken` for a fresh provider token bundle when the provider runtime supports refresh
|
|
28
|
+
- `completeAuth(context)` rotates the current session id and returns the session for auth writes
|
|
29
|
+
|
|
30
|
+
The route owns redirects, flashes, and other app-specific behavior. `remix/auth` owns the protocol work.
|
|
31
|
+
|
|
32
|
+
## Credentials Auth
|
|
33
|
+
|
|
34
|
+
Use `createCredentialsAuthProvider()` when your own server can verify submitted credentials directly, such as email/password logins.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { auth, Auth, createSessionAuthScheme, requireAuth } from 'remix/middleware/auth'
|
|
38
|
+
import { completeAuth, createCredentialsAuthProvider, verifyCredentials } from 'remix/auth'
|
|
39
|
+
import { createCookie } from 'remix/cookie'
|
|
40
|
+
import { createRouter } from 'remix/router'
|
|
41
|
+
import { formData } from 'remix/middleware/form-data'
|
|
42
|
+
import { form, route } from 'remix/routes'
|
|
43
|
+
import type { GoodAuth } from 'remix/middleware/auth'
|
|
44
|
+
import { redirect } from 'remix/response/redirect'
|
|
45
|
+
import { Session } from 'remix/session'
|
|
46
|
+
import { session } from 'remix/middleware/session'
|
|
47
|
+
import { createCookieSessionStorage } from 'remix/session-storage/cookie'
|
|
48
|
+
|
|
49
|
+
let sessionCookie = createCookie('__session', {
|
|
50
|
+
secrets: [env.SESSION_SECRET],
|
|
51
|
+
httpOnly: true,
|
|
52
|
+
secure: true,
|
|
53
|
+
sameSite: 'lax',
|
|
54
|
+
path: '/',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
let sessionStorage = createCookieSessionStorage()
|
|
58
|
+
|
|
59
|
+
let routes = route({
|
|
60
|
+
auth: {
|
|
61
|
+
session: {
|
|
62
|
+
login: form('/login'),
|
|
63
|
+
logout: { method: 'POST', pattern: '/logout' },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
app: {
|
|
67
|
+
dashboard: '/dashboard',
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
let passwordProvider = createCredentialsAuthProvider({
|
|
72
|
+
parse(context) {
|
|
73
|
+
let formData = context.get(FormData)
|
|
74
|
+
if (formData == null) {
|
|
75
|
+
throw new Error('Expected formData() middleware before verifyCredentials()')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
email: String(formData.get('email') ?? ''),
|
|
80
|
+
password: String(formData.get('password') ?? ''),
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
async verify({ email, password }) {
|
|
84
|
+
return users.verifyPassword(email, password)
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
let router = createRouter({
|
|
89
|
+
middleware: [
|
|
90
|
+
session(sessionCookie, sessionStorage),
|
|
91
|
+
formData(),
|
|
92
|
+
auth({
|
|
93
|
+
schemes: [
|
|
94
|
+
createSessionAuthScheme({
|
|
95
|
+
read(session) {
|
|
96
|
+
return session.get('auth') as { userId: string } | null
|
|
97
|
+
},
|
|
98
|
+
verify(value) {
|
|
99
|
+
return users.getById(value.userId)
|
|
100
|
+
},
|
|
101
|
+
invalidate(session) {
|
|
102
|
+
session.unset('auth')
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
],
|
|
106
|
+
}),
|
|
107
|
+
],
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
router.get(routes.auth.session.login.index, () => new Response('Login page'))
|
|
111
|
+
|
|
112
|
+
router.post(routes.auth.session.login.action, async (context) => {
|
|
113
|
+
let user = await verifyCredentials(passwordProvider, context)
|
|
114
|
+
|
|
115
|
+
if (user == null) {
|
|
116
|
+
return redirect(routes.auth.session.login.index.href())
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let session = completeAuth(context)
|
|
120
|
+
session.set('auth', { userId: user.id })
|
|
121
|
+
|
|
122
|
+
return redirect(routes.app.dashboard.href())
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
router.post(routes.auth.session.logout, ({ get }) => {
|
|
126
|
+
let session = get(Session)
|
|
127
|
+
session.unset('auth')
|
|
128
|
+
session.regenerateId(true)
|
|
129
|
+
return redirect(routes.auth.session.login.index.href())
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
router.get(routes.app.dashboard, {
|
|
133
|
+
middleware: [requireAuth()],
|
|
134
|
+
handler(context) {
|
|
135
|
+
let auth = context.get(Auth) as GoodAuth<{ id: string; email: string }>
|
|
136
|
+
|
|
137
|
+
return Response.json({
|
|
138
|
+
id: auth.identity.id,
|
|
139
|
+
email: auth.identity.email,
|
|
140
|
+
method: auth.method,
|
|
141
|
+
})
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## External Auth
|
|
147
|
+
|
|
148
|
+
Starting from the same `session()`, `auth()`, and `createSessionAuthScheme()` setup as the credentials example above, you can add a Google login flow like this. The provider is created once at module scope, and the routes compose `startExternalAuth()`, `finishExternalAuth()`, and `completeAuth()` directly.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { auth, Auth, createSessionAuthScheme, requireAuth } from 'remix/middleware/auth'
|
|
152
|
+
import {
|
|
153
|
+
completeAuth,
|
|
154
|
+
createGoogleAuthProvider,
|
|
155
|
+
finishExternalAuth,
|
|
156
|
+
refreshExternalAuth,
|
|
157
|
+
startExternalAuth,
|
|
158
|
+
} from 'remix/auth'
|
|
159
|
+
import { createCookie } from 'remix/cookie'
|
|
160
|
+
import { createRouter } from 'remix/router'
|
|
161
|
+
import { route } from 'remix/routes'
|
|
162
|
+
import type { GoodAuth } from 'remix/middleware/auth'
|
|
163
|
+
import { redirect } from 'remix/response/redirect'
|
|
164
|
+
import { session } from 'remix/middleware/session'
|
|
165
|
+
import { createCookieSessionStorage } from 'remix/session-storage/cookie'
|
|
166
|
+
|
|
167
|
+
let sessionCookie = createCookie('__session', {
|
|
168
|
+
secrets: [env.SESSION_SECRET],
|
|
169
|
+
httpOnly: true,
|
|
170
|
+
secure: true,
|
|
171
|
+
sameSite: 'lax',
|
|
172
|
+
path: '/',
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
let sessionStorage = createCookieSessionStorage()
|
|
176
|
+
|
|
177
|
+
let routes = route({
|
|
178
|
+
auth: {
|
|
179
|
+
session: {
|
|
180
|
+
login: '/login',
|
|
181
|
+
},
|
|
182
|
+
google: {
|
|
183
|
+
login: '/login/google',
|
|
184
|
+
callback: '/auth/google/callback',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
app: {
|
|
188
|
+
dashboard: '/dashboard',
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
let googleProvider = createGoogleAuthProvider({
|
|
193
|
+
clientId: env.GOOGLE_CLIENT_ID,
|
|
194
|
+
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
195
|
+
redirectUri: new URL(routes.auth.google.callback.href(), env.APP_ORIGIN),
|
|
196
|
+
authorizationParams: {
|
|
197
|
+
access_type: 'offline',
|
|
198
|
+
prompt: 'consent',
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
let router = createRouter({
|
|
203
|
+
middleware: [
|
|
204
|
+
session(sessionCookie, sessionStorage),
|
|
205
|
+
auth({
|
|
206
|
+
schemes: [
|
|
207
|
+
createSessionAuthScheme({
|
|
208
|
+
read(session) {
|
|
209
|
+
return session.get('auth') as { userId: string } | null
|
|
210
|
+
},
|
|
211
|
+
verify(value) {
|
|
212
|
+
return users.getById(value.userId)
|
|
213
|
+
},
|
|
214
|
+
invalidate(session) {
|
|
215
|
+
session.unset('auth')
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
],
|
|
219
|
+
}),
|
|
220
|
+
],
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
router.get(routes.auth.session.login, () => {
|
|
224
|
+
return new Response(`<a href="${routes.auth.google.login.href()}">Login with Google</a>`, {
|
|
225
|
+
headers: {
|
|
226
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
router.get(routes.auth.google.login, (context) =>
|
|
232
|
+
startExternalAuth(googleProvider, context, {
|
|
233
|
+
returnTo: context.url.searchParams.get('returnTo'),
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
router.get(routes.auth.google.callback, async (context) => {
|
|
238
|
+
let { result, returnTo } = await finishExternalAuth(googleProvider, context)
|
|
239
|
+
|
|
240
|
+
let user = await users.upsertFromGoogle(result.profile)
|
|
241
|
+
await persistProviderTokens(user.id, result.tokens)
|
|
242
|
+
|
|
243
|
+
let session = completeAuth(context)
|
|
244
|
+
session.set('auth', { userId: user.id })
|
|
245
|
+
|
|
246
|
+
return redirect(returnTo ?? routes.app.dashboard.href())
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
async function getGoogleAccessToken(userId: string) {
|
|
250
|
+
let tokens = await readStoredProviderTokens(userId)
|
|
251
|
+
if (tokens == null) {
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (tokens.expiresAt != null && tokens.expiresAt.getTime() <= Date.now()) {
|
|
256
|
+
tokens = (await refreshExternalAuth(googleProvider, tokens)).tokens
|
|
257
|
+
await persistProviderTokens(userId, tokens)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return tokens.accessToken
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
router.get(routes.app.dashboard, {
|
|
264
|
+
middleware: [requireAuth()],
|
|
265
|
+
handler(context) {
|
|
266
|
+
let auth = context.get(Auth) as GoodAuth<{ id: string; email: string | null }>
|
|
267
|
+
|
|
268
|
+
return Response.json({
|
|
269
|
+
id: auth.identity.id,
|
|
270
|
+
email: auth.identity.email,
|
|
271
|
+
method: auth.method,
|
|
272
|
+
})
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
A typical external auth flow looks like this:
|
|
278
|
+
|
|
279
|
+
1. Create the provider once at module scope. For Atmosphere, call `provider.prepare(handleOrDid)` only when starting the login flow.
|
|
280
|
+
2. Call `startExternalAuth()` from the login route.
|
|
281
|
+
3. Call `finishExternalAuth()` from the callback route.
|
|
282
|
+
4. Persist any provider tokens you want to reuse later.
|
|
283
|
+
5. Call `completeAuth(context)` and write your auth record into the returned session.
|
|
284
|
+
6. On a later follow-up request, load the stored provider tokens, refresh them with `refreshExternalAuth()` only if needed, then save the refreshed bundle back to storage.
|
|
285
|
+
7. Return your own redirect or other response.
|
|
286
|
+
|
|
287
|
+
## Built-in External Auth Providers
|
|
288
|
+
|
|
289
|
+
When one of the built-in providers matches your auth provider, start there. Google, Microsoft, Okta, and Auth0 use the shared OIDC runtime. GitHub, Facebook, X, and Atmosphere use built-in custom OAuth flows.
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
import {
|
|
293
|
+
createAuth0AuthProvider,
|
|
294
|
+
createAtmosphereAuthProvider,
|
|
295
|
+
createFacebookAuthProvider,
|
|
296
|
+
createGitHubAuthProvider,
|
|
297
|
+
createGoogleAuthProvider,
|
|
298
|
+
createMicrosoftAuthProvider,
|
|
299
|
+
createOktaAuthProvider,
|
|
300
|
+
createXAuthProvider,
|
|
301
|
+
} from 'remix/auth'
|
|
302
|
+
|
|
303
|
+
let auth0Provider = createAuth0AuthProvider({
|
|
304
|
+
domain: env.AUTH0_DOMAIN,
|
|
305
|
+
clientId: env.AUTH0_CLIENT_ID,
|
|
306
|
+
clientSecret: env.AUTH0_CLIENT_SECRET,
|
|
307
|
+
redirectUri: new URL('/auth/auth0/callback', env.APP_ORIGIN),
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
let atmosphereProvider = createAtmosphereAuthProvider({
|
|
311
|
+
clientId: new URL('/oauth/client-metadata.json', env.APP_ORIGIN),
|
|
312
|
+
redirectUri: new URL('/auth/atmosphere/callback', env.APP_ORIGIN),
|
|
313
|
+
sessionSecret: env.SESSION_SECRET,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
let facebookProvider = createFacebookAuthProvider({
|
|
317
|
+
clientId: env.FACEBOOK_CLIENT_ID,
|
|
318
|
+
clientSecret: env.FACEBOOK_CLIENT_SECRET,
|
|
319
|
+
redirectUri: new URL('/auth/facebook/callback', env.APP_ORIGIN),
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
let githubProvider = createGitHubAuthProvider({
|
|
323
|
+
clientId: env.GITHUB_CLIENT_ID,
|
|
324
|
+
clientSecret: env.GITHUB_CLIENT_SECRET,
|
|
325
|
+
redirectUri: new URL('/auth/github/callback', env.APP_ORIGIN),
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
let googleProvider = createGoogleAuthProvider({
|
|
329
|
+
clientId: env.GOOGLE_CLIENT_ID,
|
|
330
|
+
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
331
|
+
redirectUri: new URL('/auth/google/callback', env.APP_ORIGIN),
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
let microsoftProvider = createMicrosoftAuthProvider({
|
|
335
|
+
tenant: 'organizations',
|
|
336
|
+
clientId: env.MICROSOFT_CLIENT_ID,
|
|
337
|
+
clientSecret: env.MICROSOFT_CLIENT_SECRET,
|
|
338
|
+
redirectUri: new URL('/auth/microsoft/callback', env.APP_ORIGIN),
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
let oktaProvider = createOktaAuthProvider({
|
|
342
|
+
issuer: env.OKTA_ISSUER,
|
|
343
|
+
clientId: env.OKTA_CLIENT_ID,
|
|
344
|
+
clientSecret: env.OKTA_CLIENT_SECRET,
|
|
345
|
+
redirectUri: new URL('/auth/okta/callback', env.APP_ORIGIN),
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
let xProvider = createXAuthProvider({
|
|
349
|
+
clientId: env.X_CLIENT_ID,
|
|
350
|
+
clientSecret: env.X_CLIENT_SECRET,
|
|
351
|
+
redirectUri: new URL('/auth/x/callback', env.APP_ORIGIN),
|
|
352
|
+
})
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Notes:
|
|
356
|
+
|
|
357
|
+
- OIDC providers use discovery by default at `/.well-known/openid-configuration`
|
|
358
|
+
- Pass `metadata` when you want to skip discovery or `discoveryUrl` when the metadata document lives elsewhere
|
|
359
|
+
- Default OIDC scopes are `openid profile email`
|
|
360
|
+
- `createGoogleAuthProvider()` uses the same OIDC runtime with Google's published endpoints wired in directly, so it does not need a discovery request
|
|
361
|
+
- `createMicrosoftAuthProvider()` adds the `tenant` option and builds the issuer from it
|
|
362
|
+
- `createOktaAuthProvider()` expects the full Okta issuer URL, usually something like `https://example.okta.com/oauth2/default`
|
|
363
|
+
- `createAuth0AuthProvider()` expects your Auth0 domain and derives the issuer URL for you
|
|
364
|
+
- `createAtmosphereAuthProvider()` returns a module-scope provider with `prepare(handleOrDid)` for request-time atproto account discovery before `startExternalAuth()`
|
|
365
|
+
- Atmosphere callback routes pass the module-scope provider directly to `finishExternalAuth()`; the original handle or DID is stored in the sealed OAuth transaction state
|
|
366
|
+
- `createAtmosphereAuthProvider()` requires `sessionSecret` and seals the in-flight account, authorization server, nonce, and DPoP key state into the existing OAuth transaction stored in your app session, so you do not need a separate file or database store for the redirect step
|
|
367
|
+
- `createAtmosphereAuthProvider()` returns DPoP-bound token material in `result.tokens`, including `accessToken`, `refreshToken`, authorization server refresh details, and `dpop` JWK state for follow-up DPoP-signed requests
|
|
368
|
+
- `refreshExternalAuth()` supports built-in OIDC providers, X, and Atmosphere when the stored token bundle includes a refresh token
|
|
369
|
+
- Providers only return refresh tokens when configured to request offline access, such as `authorizationParams: { access_type: 'offline' }` for Google or adding `offline.access` to X scopes
|
|
370
|
+
- Use `mapProfile()` with `createOIDCAuthProvider()` when you want `result.profile` to have an app-specific type before it reaches your route code
|
|
371
|
+
|
|
372
|
+
Default scopes for OAuth providers that don't use OIDC discovery:
|
|
373
|
+
|
|
374
|
+
- GitHub: `read:user user:email`
|
|
375
|
+
- Facebook: `public_profile email`
|
|
376
|
+
- X: `tweet.read users.read`
|
|
377
|
+
|
|
378
|
+
Pass `scopes` if you need a different set for a provider.
|
|
379
|
+
|
|
380
|
+
## Custom Auth Providers
|
|
381
|
+
|
|
382
|
+
Use `createOIDCAuthProvider()` directly for custom external auth providers. This is the extension point for providers that support OpenID Connect discovery, authorization code flow, and a userinfo endpoint. Reach for a custom OAuth provider implementation only when the provider does not support OIDC.
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import {
|
|
386
|
+
completeAuth,
|
|
387
|
+
createOIDCAuthProvider,
|
|
388
|
+
finishExternalAuth,
|
|
389
|
+
startExternalAuth,
|
|
390
|
+
} from 'remix/auth'
|
|
391
|
+
import { redirect } from 'remix/response/redirect'
|
|
392
|
+
|
|
393
|
+
let companyProvider = createOIDCAuthProvider({
|
|
394
|
+
name: 'company',
|
|
395
|
+
issuer: 'https://sso.acme.com',
|
|
396
|
+
clientId: 'acme-web',
|
|
397
|
+
clientSecret: 'acme-web-secret',
|
|
398
|
+
redirectUri: new URL('/auth/company/callback', 'https://app.acme.com'),
|
|
399
|
+
authorizationParams: {
|
|
400
|
+
prompt: 'login',
|
|
401
|
+
},
|
|
402
|
+
mapProfile({ claims }) {
|
|
403
|
+
return {
|
|
404
|
+
id: claims.sub,
|
|
405
|
+
email: claims.email ?? null,
|
|
406
|
+
name: claims.name ?? claims.preferred_username ?? 'Unknown user',
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
router.get('/login/company', (context) =>
|
|
412
|
+
startExternalAuth(companyProvider, context, {
|
|
413
|
+
returnTo: context.url.searchParams.get('returnTo'),
|
|
414
|
+
}),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
router.get('/auth/company/callback', async (context) => {
|
|
418
|
+
let { result, returnTo } = await finishExternalAuth(companyProvider, context)
|
|
419
|
+
|
|
420
|
+
let user = await users.upsertFromCompanySSO(result.profile)
|
|
421
|
+
let session = completeAuth(context)
|
|
422
|
+
session.set('auth', { userId: user.id })
|
|
423
|
+
|
|
424
|
+
return redirect(returnTo ?? '/dashboard')
|
|
425
|
+
})
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
## Related Packages
|
|
429
|
+
|
|
430
|
+
- [`auth-middleware`](https://github.com/remix-run/remix/tree/main/packages/auth-middleware) - Request authentication and route protection helpers
|
|
431
|
+
- [`form-data-middleware`](https://github.com/remix-run/remix/tree/main/packages/form-data-middleware) - Form body parsing for `createCredentialsAuthProvider()` routes
|
|
432
|
+
- [`session-middleware`](https://github.com/remix-run/remix/tree/main/packages/session-middleware) - Request-scoped session loading and persistence
|
|
433
|
+
- [`session`](https://github.com/remix-run/remix/tree/main/packages/session) - Session data model and storage backends
|
|
434
|
+
- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Router and middleware runtime
|
|
435
|
+
|
|
436
|
+
## Related Work
|
|
437
|
+
|
|
438
|
+
- [OAuth 2.0](https://oauth.net/2/)
|
|
439
|
+
- [RFC 7636: PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
|
|
440
|
+
- [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html)
|
|
441
|
+
- [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html)
|
|
442
|
+
|
|
443
|
+
## License
|
|
444
|
+
|
|
445
|
+
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# auth-middleware
|
|
2
|
+
|
|
3
|
+
Request-time authentication and route protection for Remix. Use this package to resolve identity into `context.auth` from sessions, bearer tokens, API keys, or your own schemes. Pair it with [`remix/auth`](https://github.com/remix-run/remix/tree/main/packages/auth) when you need browser login routes that call `verifyCredentials()` or `finishExternalAuth()`, then rotate the session id with `completeAuth()` before writing the auth record.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Request auth resolution without mutating request objects
|
|
8
|
+
- Route protection with `requireAuth()` and configurable failure behavior
|
|
9
|
+
- Built-in auth schemes for sessions, bearer tokens, and API keys
|
|
10
|
+
- Ordered fallback across multiple auth schemes
|
|
11
|
+
- Public and private route support with the same resolved auth state
|
|
12
|
+
- Read auth state with `context.auth` (or `context.get(Auth)`)
|
|
13
|
+
- Designed to pair with browser login flows that persist session auth records earlier in the request lifecycle
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm i remix
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
The following example shows the request-time half of a session-backed browser login flow:
|
|
24
|
+
|
|
25
|
+
- another part of the app has already called `completeAuth()` and written `{ userId }` into the returned session
|
|
26
|
+
- `remix/middleware/auth` reads that value, resolves the current user, and protects the dashboard route
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { auth, createSessionAuthScheme, requireAuth } from 'remix/middleware/auth'
|
|
30
|
+
import { createRouter } from 'remix/router'
|
|
31
|
+
import { route } from 'remix/routes'
|
|
32
|
+
import { session } from 'remix/middleware/session'
|
|
33
|
+
|
|
34
|
+
let routes = route({
|
|
35
|
+
app: {
|
|
36
|
+
dashboard: '/dashboard',
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
type User = { id: string; email: string }
|
|
41
|
+
|
|
42
|
+
let router = createRouter({
|
|
43
|
+
middleware: [
|
|
44
|
+
session(sessionCookie, sessionStorage),
|
|
45
|
+
auth({
|
|
46
|
+
schemes: [
|
|
47
|
+
createSessionAuthScheme<User, { userId: string }>({
|
|
48
|
+
read(session) {
|
|
49
|
+
return session.get('auth') as { userId: string } | null
|
|
50
|
+
},
|
|
51
|
+
verify(value) {
|
|
52
|
+
return users.getById(value.userId)
|
|
53
|
+
},
|
|
54
|
+
invalidate(session) {
|
|
55
|
+
session.unset('auth')
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
60
|
+
],
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
router.get(routes.app.dashboard, {
|
|
64
|
+
middleware: [requireAuth<User>()],
|
|
65
|
+
handler(context) {
|
|
66
|
+
let auth = context.auth
|
|
67
|
+
if (!auth.ok) {
|
|
68
|
+
return new Response('Unauthorized', { status: 401 })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return Response.json({
|
|
72
|
+
id: auth.identity.id,
|
|
73
|
+
email: auth.identity.email,
|
|
74
|
+
method: auth.method,
|
|
75
|
+
})
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
In this example, `createSessionAuthScheme()` turns a persisted session auth record back into request auth state, `auth()` stores that state at `context.auth` (or `context.get(Auth)`), and `requireAuth()` rejects anonymous requests.
|
|
81
|
+
|
|
82
|
+
If you need to create the login route, start an OAuth redirect, finish a provider callback, or write the session auth record in the first place, use [`remix/auth`](https://github.com/remix-run/remix/tree/main/packages/auth):
|
|
83
|
+
|
|
84
|
+
- `verifyCredentials()` for direct credentials flows
|
|
85
|
+
- `startExternalAuth()` and `finishExternalAuth()` for OAuth and OIDC flows
|
|
86
|
+
- `completeAuth()` to rotate the session id before writing the auth record that this package reads later
|
|
87
|
+
|
|
88
|
+
## Route Protection
|
|
89
|
+
|
|
90
|
+
This package includes two middlewares:
|
|
91
|
+
|
|
92
|
+
- `auth()` to resolve auth state and store it in `context.auth` (or `context.get(Auth)`)
|
|
93
|
+
- `requireAuth()` to reject requests that aren't authenticated
|
|
94
|
+
|
|
95
|
+
That separation is intentional so the same auth resolution can support public routes, API routes, and browser routes with different failure behavior.
|
|
96
|
+
|
|
97
|
+
`auth()` resolves auth state and stores either `{ ok: true, identity, method }` or `{ ok: false, error? }` in `context.auth`.
|
|
98
|
+
|
|
99
|
+
Use `requireAuth()` after `auth()` when a route must be authenticated. If `auth()` did not run first, `requireAuth()` throws. Otherwise it returns `401 Unauthorized` by default, or you can replace that with `onFailure(context, auth)` to return JSON, redirects, or any other custom response.
|
|
100
|
+
|
|
101
|
+
Handlers whose context contract includes `auth()` and `requireAuth<Identity>()` can read `context.auth.identity` without a manual lookup or cast. You can also use `context.get(Auth)`.
|
|
102
|
+
|
|
103
|
+
Auth challenges are forwarded to `WWW-Authenticate` automatically when the auth failure included a `challenge`, so clients that honor those challenges can react without custom header handling.
|
|
104
|
+
|
|
105
|
+
## Auth Schemes
|
|
106
|
+
|
|
107
|
+
An `AuthScheme` is any object with a `name` and an `authenticate(context)` method. The `auth()` middleware tries each scheme in order until one returns a success or failure result. If no scheme returns success or failure, the request is treated as anonymous.
|
|
108
|
+
|
|
109
|
+
This package ships with three built-in auth schemes:
|
|
110
|
+
|
|
111
|
+
- `createBearerTokenAuthScheme()` for bearer tokens in the [HTTP `Authorization: Bearer <token>` header](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1)
|
|
112
|
+
- `createAPIAuthScheme()` for API keys in a custom request header
|
|
113
|
+
- `createSessionAuthScheme()` for session-backed auth loaded by [a `session()` middleware](https://github.com/remix-run/remix/tree/main/packages/session-middleware)
|
|
114
|
+
|
|
115
|
+
## Custom Auth Schemes
|
|
116
|
+
|
|
117
|
+
If none of the built-in auth schemes match your environment, you can create your own auth scheme easily. A custom scheme usually wraps one auth mechanism behind a small `create*` factory function and returns an `AuthScheme`. For example, apps behind a trusted access proxy can authenticate requests from forwarded identity headers instead of sessions or bearer tokens.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import type { RequestContext } from 'remix/router'
|
|
121
|
+
import type { AuthScheme } from 'remix/middleware/auth'
|
|
122
|
+
|
|
123
|
+
type User = {
|
|
124
|
+
id: string
|
|
125
|
+
role: 'admin' | 'user'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function createTrustedProxyAuthScheme(): AuthScheme<User> {
|
|
129
|
+
return {
|
|
130
|
+
name: 'trusted-proxy',
|
|
131
|
+
async authenticate(context: RequestContext) {
|
|
132
|
+
let email = context.headers.get('X-Forwarded-Email')
|
|
133
|
+
|
|
134
|
+
if (email == null) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let user = await users.getByEmail(email)
|
|
139
|
+
|
|
140
|
+
if (user == null) {
|
|
141
|
+
return {
|
|
142
|
+
status: 'failure',
|
|
143
|
+
code: 'invalid_credentials',
|
|
144
|
+
message: 'Unknown forwarded user',
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
status: 'success',
|
|
150
|
+
identity: user,
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Only use a scheme like this when the app is reachable exclusively through infrastructure you trust to set the headers you rely on. In this case, the `X-Forwarded-Email` header.
|
|
158
|
+
|
|
159
|
+
`authenticate(context)` can return:
|
|
160
|
+
|
|
161
|
+
- `null`, `undefined`, or no return value to skip this scheme
|
|
162
|
+
- `{ status: 'success', identity }` to authenticate the request
|
|
163
|
+
- `{ status: 'failure', code?, message?, challenge? }` to stop with an auth error
|
|
164
|
+
|
|
165
|
+
The scheme `name` becomes `auth.method` when authentication succeeds.
|
|
166
|
+
|
|
167
|
+
## Simple Auth Cookies
|
|
168
|
+
|
|
169
|
+
If your app already has an auth cookie and you do not need a session-backed identity lookup, you can use a small custom auth scheme and still rely on `requireAuth()` for route protection.
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { auth, requireAuth } from 'remix/middleware/auth'
|
|
173
|
+
import type { AuthScheme } from 'remix/middleware/auth'
|
|
174
|
+
import { createCookie } from 'remix/cookie'
|
|
175
|
+
import { createRouter } from 'remix/router'
|
|
176
|
+
import { redirect } from 'remix/response/redirect'
|
|
177
|
+
|
|
178
|
+
let authCookie = createCookie('__auth', {
|
|
179
|
+
httpOnly: true,
|
|
180
|
+
sameSite: 'lax',
|
|
181
|
+
path: '/',
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
let authCookieScheme: AuthScheme<'demo-user'> = {
|
|
185
|
+
name: 'auth-cookie',
|
|
186
|
+
async authenticate(context) {
|
|
187
|
+
let value = await authCookie.parse(context.headers.get('Cookie'))
|
|
188
|
+
if (value !== '1') {
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
status: 'success',
|
|
194
|
+
identity: 'demo-user',
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let requireAuthCookie = requireAuth<'demo-user'>({
|
|
200
|
+
onFailure(context) {
|
|
201
|
+
let isFrameRequest = context.request.headers.get('X-Remix-Frame') === 'true'
|
|
202
|
+
if (isFrameRequest) {
|
|
203
|
+
return new Response('<p>Not authorized</p>', {
|
|
204
|
+
status: 401,
|
|
205
|
+
headers: {
|
|
206
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return redirect('/login')
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
let router = createRouter({
|
|
216
|
+
middleware: [
|
|
217
|
+
auth({
|
|
218
|
+
schemes: [authCookieScheme],
|
|
219
|
+
}),
|
|
220
|
+
],
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
router.get('/dashboard', {
|
|
224
|
+
middleware: [requireAuthCookie],
|
|
225
|
+
handler() {
|
|
226
|
+
return new Response('ok')
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
This pattern keeps the auth check app-owned. Use [`remix/middleware/session`](https://github.com/remix-run/remix/tree/main/packages/session-middleware) and [`remix/auth`](https://github.com/remix-run/remix/tree/main/packages/auth) when you need server-managed session data, credential verification helpers, or OAuth/OIDC flows.
|
|
232
|
+
|
|
233
|
+
## Related Packages
|
|
234
|
+
|
|
235
|
+
- [`auth`](https://github.com/remix-run/remix/tree/main/packages/auth) - Browser auth primitives for credentials, OAuth, and OIDC flows
|
|
236
|
+
- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Router and middleware runtime
|
|
237
|
+
- [`response`](https://github.com/remix-run/remix/tree/main/packages/response) - Response helpers like redirects
|
|
238
|
+
|
|
239
|
+
## Related Work
|
|
240
|
+
|
|
241
|
+
- [HTTP Authentication Framework](https://datatracker.ietf.org/doc/html/rfc7235)
|
|
242
|
+
- [OAuth 2.0 Bearer Token Usage](https://datatracker.ietf.org/doc/html/rfc6750)
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
|