Skip to content

Commit c19dbd1

Browse files
committed
Update v-api. Start auth package
1 parent 7d0e9e9 commit c19dbd1

File tree

20 files changed

+3003
-186
lines changed

20 files changed

+3003
-186
lines changed

Cargo.lock

Lines changed: 156 additions & 138 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

remix-auth-rfd/package-lock.json

Lines changed: 2183 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

remix-auth-rfd/package.json

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"name": "@oxide/remix-auth-rfd",
3+
"version": "0.1.0",
4+
"engines": {
5+
"node": ">=18"
6+
},
7+
"type": "module",
8+
"main": "./dist/index.js",
9+
"exports": {
10+
"import": "./dist/index.js",
11+
"require": "./dist/index.cjs"
12+
},
13+
"scripts": {
14+
"build": "tsup --dts",
15+
"prepublishOnly": "npm run build",
16+
"test": "echo \"Error: no test specified\" && exit 1",
17+
"tsc": "tsc"
18+
},
19+
"keywords": [
20+
"remix",
21+
"remix-auth",
22+
"auth",
23+
"authentication",
24+
"strategy"
25+
],
26+
"author": "Oxide Computer Company",
27+
"license": "MPL-2.0",
28+
"repository": {
29+
"type": "git",
30+
"url": "git+https://github.com/oxidecomputer/rfd-api.git"
31+
},
32+
"peerDependencies": {
33+
"@remix-run/server-runtime": "^1.0.0 || ^2.0.0",
34+
"remix-auth": "^4.0.0"
35+
},
36+
"devDependencies": {
37+
"tsup": "^8.0.2",
38+
"typescript": "^5.7.2"
39+
},
40+
"tsup": {
41+
"clean": true,
42+
"entry": [
43+
"src/index.ts"
44+
],
45+
"format": [
46+
"cjs",
47+
"esm"
48+
]
49+
},
50+
"dependencies": {
51+
"@oxide/rfd.ts": "^0.2.0",
52+
"remix-auth-oauth2": "^3.0.0"
53+
}
54+
}

remix-auth-rfd/src/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
export type RfdScope =
10+
| 'user:info:r'
11+
| 'user:info:w'
12+
| 'user:provider:w'
13+
| 'user:token:r'
14+
| 'user:token:w'
15+
| 'group:info:r'
16+
| 'mapper:r'
17+
| 'mapper:w'
18+
| 'rfd:content:r'
19+
| 'rfd:discussion:r'
20+
| 'search'
21+
22+
export type RfdApiProvider = 'email' | 'google'
23+
24+
export type RfdAccessToken = {
25+
iss: string
26+
aud: string
27+
sub: string
28+
prv: string
29+
scp: string[]
30+
exp: number
31+
nbf: number
32+
jti: string
33+
}
34+
35+
export * from './magic-link'
36+
export * from './oauth'

remix-auth-rfd/src/magic-link.ts

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
// /*
2+
// * This Source Code Form is subject to the terms of the Mozilla Public
3+
// * License, v. 2.0. If a copy of the MPL was not distributed with this
4+
// * file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
// *
6+
// * Copyright Oxide Computer Company
7+
// */
8+
9+
import { Api, ApiResult, GetUserResponse_for_RfdPermission } from '@oxide/rfd.ts/client'
10+
import type { SessionStorage } from '@remix-run/server-runtime'
11+
import { redirect } from '@remix-run/server-runtime'
12+
import { Strategy } from 'remix-auth/strategy'
13+
14+
import { RfdScope } from '.'
15+
import { client } from './util'
16+
17+
export class InvalidMethod extends Error {}
18+
export class MissingRequiredField extends Error {}
19+
export class RemoteError extends Error {}
20+
21+
export type TurnstileMagicLinkStrategyOptions = {
22+
storage: SessionStorage
23+
host: string
24+
clientSecret: string
25+
channel: string
26+
linkExpirationTime: number
27+
pendingPath: string
28+
returnPath: string
29+
/**
30+
* @default "user:info:r"
31+
*/
32+
scope?: RfdScope[]
33+
}
34+
35+
export type TurnstileMagicLinkVerifyParams = {
36+
attemptId: string
37+
email: string
38+
user: GetUserResponse_for_RfdPermission
39+
token: string
40+
}
41+
42+
export class TurnstileMagicLinkStrategy<User> extends Strategy<User, TurnstileMagicLinkVerifyParams> {
43+
public name = 'rfd-magic-link'
44+
45+
// Session based storage to use for storing the client side authentication materials
46+
// for tracking the authentication flow
47+
private readonly storage: SessionStorage
48+
49+
// Turnstile server to perform authentication against
50+
private readonly host: string
51+
52+
// Client secret that will be used to exchange magic link codes for user information
53+
private readonly clientSecret: string
54+
55+
// Channel the send the magic link over to
56+
private readonly channel: string
57+
58+
// Scopes to request for the user during authentication
59+
private readonly scope: RfdScope[]
60+
61+
// Duration for which the magic link will be valid
62+
private readonly linkExpirationTime: number
63+
64+
// Path to redirect the user to upon issuing a magic link send
65+
private readonly pendingPath: string
66+
67+
// Path that the user should reuturn to via the sent magic link
68+
private readonly returnPath: string
69+
70+
private readonly emailField: string = 'email'
71+
private readonly authnCodeSearchParam: string = 'code'
72+
73+
private readonly sessionAttemptKey: string = 'auth:v-ml:attempt'
74+
private readonly sessionEmailKey: string = 'auth:v-ml:email'
75+
76+
protected verify: Strategy.VerifyFunction<User, TurnstileMagicLinkVerifyParams>
77+
78+
constructor(
79+
options: TurnstileMagicLinkStrategyOptions,
80+
verify: Strategy.VerifyFunction<User, TurnstileMagicLinkVerifyParams>,
81+
) {
82+
super(verify)
83+
this.verify = verify
84+
85+
this.storage = options.storage
86+
this.host = options.host
87+
this.pendingPath = options.pendingPath
88+
this.returnPath = options.returnPath
89+
this.clientSecret = options.clientSecret
90+
this.channel = options.channel
91+
this.scope = options.scope ?? ['user:info:r']
92+
this.linkExpirationTime = options.linkExpirationTime
93+
}
94+
95+
public async authenticate(
96+
request: Request,
97+
): Promise<User> {
98+
if (request.method === 'GET') {
99+
return await this.handleReturnRequest(request)
100+
} else if (request.method === 'POST') {
101+
return await this.handleSendRequest(request)
102+
} else {
103+
throw new InvalidMethod(request.method)
104+
}
105+
}
106+
107+
private async handleSendRequest(
108+
request: Request,
109+
): Promise<User> {
110+
const session = await sessionStorage.getSession(
111+
request.headers.get('Cookie'),
112+
)
113+
114+
// Verify pre-conditions:
115+
// 1. This must be a POST
116+
// 2. Request body should contain the FormData including an email field
117+
// 3. Validate options
118+
// a. successRedirect is non-empty
119+
if (request.method !== 'POST') {
120+
throw new InvalidMethod(request.method)
121+
}
122+
123+
const form = new URLSearchParams(await request.text())
124+
const email = form.get(this.emailField)
125+
126+
if (!email) {
127+
throw new MissingRequiredField('email')
128+
}
129+
130+
try {
131+
// Request email send via rfd API
132+
const rfd = client(this.host)
133+
134+
const redirectUri = this.getDomainUrl(request)
135+
redirectUri.pathname = this.returnPath
136+
137+
const response = await rfd.methods.magicLinkSend({
138+
path: { channel: this.channel },
139+
body: {
140+
expiresIn: this.linkExpirationTime,
141+
medium: 'email',
142+
recipient: email,
143+
redirectUri: redirectUri.toString(),
144+
secret: this.clientSecret,
145+
scope: this.scope.join(' '),
146+
},
147+
})
148+
149+
const attemptId = (await this.handleApiResponse(response)).attemptId
150+
151+
session.set(this.sessionAttemptKey, attemptId)
152+
session.set(this.sessionEmailKey, email)
153+
} catch (err) {
154+
console.error('Turnstile server failed to send magic link email', err)
155+
throw new RemoteError('Failed to send magic link email')
156+
}
157+
158+
const cookies = await sessionStorage.commitSession(session)
159+
throw redirect(this.returnPath, {
160+
headers: {
161+
'Set-Cookie': cookies,
162+
},
163+
})
164+
}
165+
166+
private async handleReturnRequest(
167+
request: Request,
168+
): Promise<User> {
169+
const session = await sessionStorage.getSession(
170+
request.headers.get('Cookie'),
171+
)
172+
173+
// Verify pre-conditions:
174+
// 1. This must be a GET
175+
// 2. Request query should contain and authn code
176+
// 3. Session key is set
177+
// 4. Session attempt exists
178+
// 5. Session email exists
179+
if (request.method !== 'GET') {
180+
throw new InvalidMethod(request.method)
181+
}
182+
183+
const code = new URL(request.url).searchParams.get(this.authnCodeSearchParam)
184+
if (!code) {
185+
throw new Error('Missing code parameter')
186+
}
187+
188+
const attemptId = session.get(this.sessionAttemptKey)
189+
if (!attemptId) {
190+
throw new Error(
191+
'Missing attemptId in session. This link may not have been intended for the current browser',
192+
)
193+
}
194+
195+
const email = session.get(this.sessionEmailKey)
196+
if (!email) {
197+
throw new Error('Missing email in session. This link may not have been intended for the current browser')
198+
}
199+
200+
let token
201+
let expiresIn
202+
try {
203+
// Send exchange request to rfd with authn code and secret to retrieve
204+
// an access token
205+
const rfd = client(this.host)
206+
const response = await this.handleApiResponse(
207+
await rfd.methods.magicLinkExchange({
208+
path: { channel: this.channel },
209+
body: {
210+
attemptId,
211+
recipient: email,
212+
secret: code,
213+
},
214+
}),
215+
)
216+
token = response.accessToken
217+
expiresIn = response.expiresIn
218+
219+
// Once the exchange has completed, clear the attempt key
220+
session.unset(this.sessionAttemptKey)
221+
session.unset(this.sessionEmailKey)
222+
} catch (err) {
223+
console.error('Failed to exchange authentication code of user credentials', err)
224+
throw new RemoteError('Failed to exchange authentication code of user credentials')
225+
}
226+
227+
let user
228+
try {
229+
// Crete a new client with the token so that authenticated calls can be made
230+
const rfd = client(this.host, token)
231+
const apiUser = await this.handleApiResponse(await rfd.methods.getSelf({}, {}))
232+
233+
user = await this.verify({ attemptId, email, user: apiUser, token })
234+
} catch (err) {
235+
console.error('Failed to retrieve user data', err)
236+
throw new RemoteError('Failed to retrieve user data')
237+
}
238+
239+
return user
240+
}
241+
242+
private getDomainUrl(request: Request): URL {
243+
const host = request.headers.get('X-Forwarded-Host') ?? request.headers.get('host')
244+
245+
if (!host) {
246+
throw new Error('Could not determine domain URL.')
247+
}
248+
249+
const protocol = host.includes('localhost') || host.includes('127.0.0.1')
250+
? 'http'
251+
: request.headers.get('X-Forwarded-Proto') ?? 'https'
252+
253+
return new URL(`${protocol}://${host}`)
254+
}
255+
256+
private async handleApiResponse<T>(response: ApiResult<T>): Promise<T> {
257+
if (response.type === 'success') {
258+
return response.data
259+
} else if (response.type === 'client_error') {
260+
console.error('Failed attempting to send request to rfd server', response)
261+
throw response.error as Error
262+
} else {
263+
console.error('Failed attempting to send request to rfd server', response)
264+
throw new Error(response.data.message)
265+
}
266+
}
267+
}

0 commit comments

Comments
 (0)