|
| 1 | +import { |
| 2 | + RouteContext, |
| 3 | + RuntimeError, |
| 4 | + RouteRequest, |
| 5 | + RouteResponse, |
| 6 | +} from "../module.gen.ts"; |
| 7 | + |
| 8 | +import { getFullConfig } from "../utils/env.ts"; |
| 9 | +import { getClient } from "../utils/client.ts"; |
| 10 | +import { getUserUniqueIdentifier } from "../utils/client.ts"; |
| 11 | +import { Tokens } from "https://deno.land/x/oauth2_client@v1.0.2/mod.ts"; |
| 12 | + |
| 13 | +import { compareConstantTime, stateToDataStr } from "../utils/state.ts"; |
| 14 | +import { OAUTH_DONE_HTML } from "../utils/pages.ts"; |
| 15 | + |
| 16 | +export async function handle( |
| 17 | + ctx: RouteContext, |
| 18 | + req: RouteRequest, |
| 19 | +): Promise<RouteResponse> { |
| 20 | + // Max 5 login attempts per IP per minute |
| 21 | + ctx.modules.rateLimit.throttlePublic({ requests: 5, period: 60 }); |
| 22 | + |
| 23 | + // Ensure that the provider configurations are valid |
| 24 | + const config = await getFullConfig(ctx.config); |
| 25 | + if (!config) throw new RuntimeError("invalid_config", { statusCode: 500 }); |
| 26 | + |
| 27 | + // Get the URI that this request was made to |
| 28 | + const uri = new URL(req.url); |
| 29 | + |
| 30 | + // Get the state from the URI |
| 31 | + const redirectedState = uri.searchParams.get("state"); |
| 32 | + if (!redirectedState) { |
| 33 | + throw new RuntimeError("missing_state", { statusCode: 400 }); |
| 34 | + } |
| 35 | + |
| 36 | + // Extract the data from the state |
| 37 | + const stateData = await stateToDataStr(config.oauthSecret, redirectedState); |
| 38 | + const { flowId, providerId } = JSON.parse(stateData); |
| 39 | + |
| 40 | + // Get the login attempt stored in the database |
| 41 | + const loginAttempt = await ctx.db.loginAttempts.findUnique({ |
| 42 | + where: { |
| 43 | + id: flowId, |
| 44 | + }, |
| 45 | + }); |
| 46 | + if (!loginAttempt) throw new RuntimeError("login_not_found", { statusCode: 400 }); |
| 47 | + |
| 48 | + // Check if the login attempt is valid |
| 49 | + if (loginAttempt.completedAt) { |
| 50 | + throw new RuntimeError("login_already_completed", { statusCode: 400 }); |
| 51 | + } |
| 52 | + if (loginAttempt.invalidatedAt) { |
| 53 | + throw new RuntimeError("login_cancelled", { statusCode: 400 }); |
| 54 | + } |
| 55 | + if (new Date(loginAttempt.expiresAt) < new Date()) { |
| 56 | + throw new RuntimeError("login_expired", { statusCode: 400 }); |
| 57 | + } |
| 58 | + |
| 59 | + // Check if the provider ID and state match |
| 60 | + const providerIdMatch = compareConstantTime(loginAttempt.providerId, providerId); |
| 61 | + const stateMatch = compareConstantTime(loginAttempt.state, redirectedState); |
| 62 | + if (!providerIdMatch || !stateMatch) throw new RuntimeError("invalid_state", { statusCode: 400 }); |
| 63 | + |
| 64 | + const { state, codeVerifier } = loginAttempt; |
| 65 | + |
| 66 | + // Get the provider config |
| 67 | + const provider = config.providers[providerId]; |
| 68 | + if (!provider) throw new RuntimeError("invalid_provider", { statusCode: 400 }); |
| 69 | + |
| 70 | + // Get the oauth client |
| 71 | + const client = getClient(config, provider.name); |
| 72 | + if (!client.config.redirectUri) throw new RuntimeError("invalid_config", { statusCode: 500 }); |
| 73 | + |
| 74 | + // Get the user's tokens and sub |
| 75 | + let tokens: Tokens; |
| 76 | + let ident: string; |
| 77 | + try { |
| 78 | + tokens = await client.code.getToken(uri.toString(), { state, codeVerifier }); |
| 79 | + ident = await getUserUniqueIdentifier(tokens.accessToken, provider); |
| 80 | + } catch (e) { |
| 81 | + console.error(e); |
| 82 | + throw new RuntimeError("invalid_oauth_response", { statusCode: 502 }); |
| 83 | + } |
| 84 | + |
| 85 | + // Update the login attempt |
| 86 | + await ctx.db.loginAttempts.update({ |
| 87 | + where: { |
| 88 | + id: flowId, |
| 89 | + }, |
| 90 | + data: { |
| 91 | + identifier: ident, |
| 92 | + tokenData: { ...tokens }, |
| 93 | + completedAt: new Date(), |
| 94 | + }, |
| 95 | + }); |
| 96 | + |
| 97 | + return new RouteResponse( |
| 98 | + OAUTH_DONE_HTML, |
| 99 | + { |
| 100 | + status: 200, |
| 101 | + headers: { |
| 102 | + "Content-Type": "text/html", |
| 103 | + }, |
| 104 | + }, |
| 105 | + ); |
| 106 | +} |
0 commit comments