|
| 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