From 6c6bb4769dfbfffce42959f54b7f4a4354b49ec7 Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Thu, 27 Jun 2024 17:12:55 -0400 Subject: [PATCH] feat: Integrate the `auth_oauth2` module with the new `auth` module --- modules/auth/module.json | 7 +- modules/auth/scripts/complete_flow.ts | 19 --- modules/auth/scripts/start_login_flow.ts | 8 +- modules/auth/utils/flow.ts | 30 +++- modules/auth/utils/providers.ts | 69 +++++++++ modules/auth/utils/types.ts | 7 +- modules/auth_oauth2/config.ts | 12 +- modules/auth_oauth2/module.json | 117 +++++++------- modules/auth_oauth2/public.ts | 1 + modules/auth_oauth2/routes/login_callback.ts | 145 +++++++----------- modules/auth_oauth2/routes/login_link.ts | 91 ----------- modules/auth_oauth2/scripts/get_login_data.ts | 41 +++++ modules/auth_oauth2/scripts/init_flow.ts | 59 +++++++ modules/auth_oauth2/utils/client.ts | 25 ++- modules/auth_oauth2/utils/env.ts | 75 ++++----- modules/auth_oauth2/utils/state.ts | 101 +++++++++--- modules/auth_oauth2/utils/trace.ts | 51 ------ modules/auth_oauth2/utils/types.ts | 3 + modules/auth_oauth2/utils/wellknown.ts | 69 +++++---- modules/tokens/scripts/revoke.ts | 6 +- modules/users/scripts/authenticate_token.ts | 18 +-- tests/basic/backend.json | 16 +- tests/basic/deno.lock | 13 ++ 23 files changed, 544 insertions(+), 439 deletions(-) delete mode 100644 modules/auth/scripts/complete_flow.ts create mode 100644 modules/auth/utils/providers.ts create mode 100644 modules/auth_oauth2/public.ts delete mode 100644 modules/auth_oauth2/routes/login_link.ts create mode 100644 modules/auth_oauth2/scripts/get_login_data.ts create mode 100644 modules/auth_oauth2/scripts/init_flow.ts delete mode 100644 modules/auth_oauth2/utils/trace.ts create mode 100644 modules/auth_oauth2/utils/types.ts diff --git a/modules/auth/module.json b/modules/auth/module.json index f5df9828..4dbb86cc 100644 --- a/modules/auth/module.json +++ b/modules/auth/module.json @@ -16,7 +16,8 @@ "email": {}, "users": {}, "rate_limit": {}, - "tokens": {} + "tokens": {}, + "auth_oauth2": {} }, "scripts": { "get_flow_status": { @@ -28,10 +29,6 @@ "name": "Cancel Flow", "description": "Cancels a login flow. This is irreversible and will error if the flow is not `pending`." }, - "complete_flow": { - "name": "Complete Flow", - "description": "Completes a login flow and generates a user token. This is irreversible and will error if the flow is not `pending`." - }, "list_providers": { "name": "Send Email Verification", "description": "Send a one-time verification code to a user's email address to authenticate them.", diff --git a/modules/auth/scripts/complete_flow.ts b/modules/auth/scripts/complete_flow.ts deleted file mode 100644 index b6c6fbc9..00000000 --- a/modules/auth/scripts/complete_flow.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ScriptContext } from "../module.gen.ts"; -import { completeFlow } from "../utils/flow.ts"; - -export interface Request { - flowToken: string; - userId: string; -} -export interface Response { - userToken: string; -} - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - return { - userToken: await completeFlow(ctx, req.flowToken, req.userId), - }; -} diff --git a/modules/auth/scripts/start_login_flow.ts b/modules/auth/scripts/start_login_flow.ts index 3bac344d..5d890b69 100644 --- a/modules/auth/scripts/start_login_flow.ts +++ b/modules/auth/scripts/start_login_flow.ts @@ -1,4 +1,6 @@ import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts"; +import { createFlowToken } from "../utils/flow.ts"; +import { initFlowWithProvider } from "../utils/providers.ts"; import { Provider } from "../utils/types.ts"; export interface Request { @@ -6,11 +8,15 @@ export interface Request { } export interface Response { urlForLoginLink: string; + token: string; } export async function run( ctx: ScriptContext, req: Request, ): Promise { - throw new RuntimeError("todo", { statusCode: 500 }); + const token = await createFlowToken(ctx, req.provider); + const url = await initFlowWithProvider(ctx, token.token, req.provider); + + return { token: token.token, urlForLoginLink: url }; } diff --git a/modules/auth/utils/flow.ts b/modules/auth/utils/flow.ts index a2e5e00d..39f4f082 100644 --- a/modules/auth/utils/flow.ts +++ b/modules/auth/utils/flow.ts @@ -1,4 +1,6 @@ import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { pollProvider } from "./providers.ts"; +import { Provider } from "./types.ts"; /** * The token type that designates that this is a flow token @@ -30,11 +32,11 @@ function getExpiryTime() { * @returns A flow token (TokenWithSecret) with the correct meta and expiry * time. */ -export async function createFlowToken(ctx: ScriptContext) { +export async function createFlowToken(ctx: ScriptContext, provider: Provider) { const { token } = await ctx.modules.tokens.create({ type: FLOW_TYPE, - meta: {}, - expireAt: getExpiryTime().toString(), + meta: { provider }, + expireAt: getExpiryTime().toISOString(), }); return token; } @@ -69,14 +71,23 @@ export async function getFlowStatus( return { status: "cancelled" }; } else if (expireDate.getTime() <= Date.now()) { return { status: "expired" }; - } else if (!flowData.meta.userToken) { - return { status: "pending" }; - } else { + } else if (flowData.meta.userToken) { return { status: "complete", userToken: flowData.meta.userToken.toString(), }; } + + const provider = flowData.meta.provider; + const pollResult = await pollProvider(ctx, flowToken, provider); + if (pollResult) { + return { + status: "complete", + userToken: pollResult, + }; + } else { + return { status: "pending" }; + } } export async function cancelFlow( @@ -106,6 +117,7 @@ export async function completeFlow( ctx: ScriptContext, flowToken: string, userId: string, + additionalData: unknown, ): Promise { const status = await getFlowStatus(ctx, flowToken); switch (status.status) { @@ -124,6 +136,12 @@ export async function completeFlow( token: flowToken, newMeta: { userToken: token.token }, }); + await ctx.modules.tokens.modifyMeta({ + token: token.token, + newMeta: { + data: additionalData, + }, + }); return token.token; } diff --git a/modules/auth/utils/providers.ts b/modules/auth/utils/providers.ts new file mode 100644 index 00000000..65299677 --- /dev/null +++ b/modules/auth/utils/providers.ts @@ -0,0 +1,69 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { completeFlow } from "./flow.ts"; +import { OAuthProvider, Provider, ProviderType } from "./types.ts"; + +function getAuthProviderType(provider: Provider): ProviderType { + if (ProviderType.EMAIL in provider) { + return ProviderType.EMAIL; + } else if (ProviderType.OAUTH in provider) { + console.log("Provider is oauth:", provider); + return ProviderType.OAUTH; + } else { + throw new RuntimeError("invalid_provider"); + } +} + +export async function initFlowWithProvider( + ctx: ScriptContext, + flowToken: string, + provider: Provider, +): Promise { + switch (getAuthProviderType(provider)) { + case ProviderType.EMAIL: + throw new Error("todo"); + + case ProviderType.OAUTH: { + const { urlForLoginLink } = await ctx.modules.authOauth2.initFlow({ + flowToken, + providerIdent: (provider as OAuthProvider).oauth, + }); + return urlForLoginLink; + } + } +} + +export async function pollProvider( + ctx: ScriptContext, + flowToken: string, + provider: Provider, +): Promise { + switch (getAuthProviderType(provider)) { + case ProviderType.EMAIL: + throw new Error("todo"); + + case ProviderType.OAUTH: { + const { details } = await ctx.modules.authOauth2.getLoginData({ + flowToken, + providerIdent: (provider as OAuthProvider).oauth, + }); + if (!details) return null; + + const identity = await ctx.db.identityOAuth.findFirst({ + where: { + subId: details.sub, + provider: details.provider, + }, + }); + if (!identity) throw new Error("todo"); + + const userToken = await completeFlow( + ctx, + flowToken, + identity.userId, + details.retainedTokenDetails, + ); + + return userToken; + } + } +} diff --git a/modules/auth/utils/types.ts b/modules/auth/utils/types.ts index a53cf2e8..f950d40f 100644 --- a/modules/auth/utils/types.ts +++ b/modules/auth/utils/types.ts @@ -1,3 +1,5 @@ +import { Module } from "../module.gen.ts"; + export interface FlowToken { token: string; } @@ -16,6 +18,9 @@ export type EmailProvider = Record< ProviderType.EMAIL, { passwordless: boolean } >; -export type OAuthProvider = Record; +export type OAuthProvider = Record< + ProviderType.OAUTH, + Module.authOauth2.ProviderIdentifierDetails +>; export type Provider = EmailProvider | OAuthProvider; diff --git a/modules/auth_oauth2/config.ts b/modules/auth_oauth2/config.ts index 76e61b58..2d093193 100644 --- a/modules/auth_oauth2/config.ts +++ b/modules/auth_oauth2/config.ts @@ -1,11 +1,11 @@ export interface Config { - providers: Record; + providers: Record; } export interface ProviderEndpoints { - authorization: string; - token: string; - userinfo: string; - scopes: string; - userinfoKey: string; + authorization: string; + token: string; + userinfo: string; + scopes: string; + userinfoKey: string; } diff --git a/modules/auth_oauth2/module.json b/modules/auth_oauth2/module.json index 2c68c3b0..1da070de 100644 --- a/modules/auth_oauth2/module.json +++ b/modules/auth_oauth2/module.json @@ -1,58 +1,61 @@ { - "name": "OAuth2 Authentication Provider", - "description": "Authenticate users with OAuth 2.0.", - "icon": "key", - "tags": [ - "core", - "user", - "auth" - ], - "authors": [ - "rivet-gg", - "Skyler Calaman" - ], - "status": "beta", - "dependencies": { - "rate_limit": {}, - "users": {}, - "tokens": {} - }, - "routes": { - "login_link": { - "name": "Login Link", - "description": "Generate a login link for accessing OpenGB.", - "method": "GET", - "pathPrefix": "/login/" - }, - "login_callback": { - "name": "OAuth Redirect Callback", - "description": "Verify a user's OAuth login and create a session.", - "method": "GET", - "pathPrefix": "/callback/" - } - }, - "scripts": {}, - "errors": { - "already_friends": { - "name": "Already Friends" - }, - "friend_request_not_found": { - "name": "Friend Request Not Found" - }, - "friend_request_already_exists": { - "name": "Friend Request Already Exists" - }, - "not_friend_request_recipient": { - "name": "Not Friend Request Recipient" - }, - "friend_request_already_accepted": { - "name": "Friend Request Already Accepted" - }, - "friend_request_already_declined": { - "name": "Friend Request Already Declined" - }, - "cannot_send_to_self": { - "name": "Cannot Send to Self" - } - } -} \ No newline at end of file + "name": "OAuth2 Authentication Provider", + "description": "Authenticate users with OAuth 2.0.", + "icon": "key", + "tags": [ + "core", + "user", + "auth" + ], + "authors": [ + "rivet-gg", + "Skyler Calaman" + ], + "status": "beta", + "dependencies": { + "rate_limit": {}, + "users": {}, + "tokens": {} + }, + "routes": { + "login_callback": { + "name": "OAuth Redirect Callback", + "description": "Verify a user's OAuth login and create a session.", + "method": "GET", + "pathPrefix": "/callback/" + } + }, + "scripts": { + "init_flow": { + "name": "Initialize Auth Flow", + "description": "Update flow token for OAuth login and generate an authorization URI." + }, + "get_login_data": { + "name": "Get Login Data", + "description": "Update flow token for OAuth login and generate an authorization URI." + } + }, + "errors": { + "already_friends": { + "name": "Already Friends" + }, + "friend_request_not_found": { + "name": "Friend Request Not Found" + }, + "friend_request_already_exists": { + "name": "Friend Request Already Exists" + }, + "not_friend_request_recipient": { + "name": "Not Friend Request Recipient" + }, + "friend_request_already_accepted": { + "name": "Friend Request Already Accepted" + }, + "friend_request_already_declined": { + "name": "Friend Request Already Declined" + }, + "cannot_send_to_self": { + "name": "Cannot Send to Self" + } + } +} diff --git a/modules/auth_oauth2/public.ts b/modules/auth_oauth2/public.ts new file mode 100644 index 00000000..9c0f7327 --- /dev/null +++ b/modules/auth_oauth2/public.ts @@ -0,0 +1 @@ +export type { ProviderIdentifierDetails } from "./utils/types.ts"; diff --git a/modules/auth_oauth2/routes/login_callback.ts b/modules/auth_oauth2/routes/login_callback.ts index bcc3c166..7b9fdce3 100644 --- a/modules/auth_oauth2/routes/login_callback.ts +++ b/modules/auth_oauth2/routes/login_callback.ts @@ -1,126 +1,99 @@ import { RouteContext, - RuntimeError, RouteRequest, RouteResponse, + RuntimeError, } from "../module.gen.ts"; -import { getCodeVerifierFromCookie, getStateFromCookie, getLoginIdFromCookie } from "../utils/trace.ts"; import { getFullConfig } from "../utils/env.ts"; import { getClient } from "../utils/client.ts"; import { getUserUniqueIdentifier } from "../utils/client.ts"; import { Tokens } from "https://deno.land/x/oauth2_client@v1.0.2/mod.ts"; +import { extractTokenFromState } from "../utils/state.ts"; +import { compareConstantTime } from "../utils/state.ts"; export async function handle( ctx: RouteContext, req: RouteRequest, ): Promise { // Max 2 login attempts per IP per minute - ctx.modules.rateLimit.throttlePublic({ requests: 5, period: 60 }); + // ctx.modules.rateLimit.throttlePublic({ requests: 5, period: 60 }); // Ensure that the provider configurations are valid - const config = await getFullConfig(ctx.userConfig); + const config = await getFullConfig(ctx.config); if (!config) throw new RuntimeError("invalid_config", { statusCode: 500 }); - const loginId = getLoginIdFromCookie(ctx); - const codeVerifier = getCodeVerifierFromCookie(ctx); - const state = getStateFromCookie(ctx); - - if (!loginId || !codeVerifier || !state) throw new RuntimeError("missing_login_data", { statusCode: 400 }); + // Get the URI that this request was made to + const uri = new URL(req.url); + // Get the state from the URI + const redirectedState = uri.searchParams.get("state"); + if (!redirectedState) { + throw new RuntimeError("missing_state", { statusCode: 400 }); + } - // Get the login attempt stored in the database - const loginAttempt = await ctx.db.oAuthLoginAttempt.findUnique({ - where: { id: loginId, completedAt: null, invalidatedAt: null }, + // Extract the token from the state + const redirectedFlowToken = await extractTokenFromState( + config.oauthSecret, + redirectedState, + ); + const { tokens: [flowTokenData] } = await ctx.modules.tokens.fetchByToken({ + tokens: [redirectedFlowToken], }); - if (!loginAttempt) throw new RuntimeError("login_not_found", { statusCode: 400 }); - if (loginAttempt.state !== state) throw new RuntimeError("invalid_state", { statusCode: 400 }); - if (loginAttempt.codeVerifier !== codeVerifier) throw new RuntimeError("invalid_code_verifier", { statusCode: 400 }); - - // Get the provider config - const provider = config.providers[loginAttempt.provider]; - if (!provider) throw new RuntimeError("invalid_provider", { statusCode: 400 }); + // Get and verify the provider, state, and code verifier from the token + // metadata + const { meta } = flowTokenData; + const { provider, state, codeVerifier } = meta.oauthData as Record< + string, + unknown + >; + + if ( + !provider || !state || !codeVerifier || + typeof provider !== "string" || typeof state !== "string" || + typeof codeVerifier !== "string" + ) throw new RuntimeError("missing_oauth_data", { statusCode: 400 }); + if (!compareConstantTime(state, redirectedState)) { + throw new RuntimeError("invalid_state", { statusCode: 400 }); + } // Get the oauth client - const client = getClient(config, provider.name, new URL(req.url)); - if (!client.config.redirectUri) throw new RuntimeError("invalid_config", { statusCode: 500 }); - - - // Get the URI that this request was made to - const uri = new URL(req.url); - const uriStr = uri.toString(); + const client = getClient(config, provider); + if (!client.config.redirectUri) { + throw new RuntimeError("invalid_config", { statusCode: 500 }); + } // Get the user's tokens and sub let tokens: Tokens; let sub: string; try { - tokens = await client.code.getToken(uriStr, { state, codeVerifier }); - sub = await getUserUniqueIdentifier(tokens.accessToken, provider); + tokens = await client.code.getToken(uri.toString(), { + state, + codeVerifier, + }); + + sub = await getUserUniqueIdentifier( + tokens.accessToken, + config.providers[provider], + ); } catch (e) { console.error(e); throw new RuntimeError("invalid_oauth_response", { statusCode: 502 }); } - const expiresIn = tokens.expiresIn ?? 3600; - const expiry = new Date(Date.now() + expiresIn); - - // Ensure the user is registered with this sub/provider combo - const user = await ctx.db.oAuthUsers.findFirst({ - where: { + // Update the token to include the finished details + const newMeta = { + ...meta, + oauth: { + provider, sub, - provider: loginAttempt.provider, + tokens, }, - }); - - let userId: string; - if (user) { - userId = user.userId; - } else { - const { user: newUser } = await ctx.modules.users.createUser({ username: sub }); - await ctx.db.oAuthUsers.create({ - data: { - sub, - provider: loginAttempt.provider, - userId: newUser.id, - }, - }); - - userId = newUser.id; - } - - // Generate a token which the user can use to authenticate with this module - const { token } = await ctx.modules.users.createUserToken({ userId }); - - // Record the credentials - await ctx.db.oAuthCreds.create({ - data: { - loginAttemptId: loginAttempt.id, - provider: provider.name, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken ?? "", - userToken: token.token, - expiresAt: expiry, - }, - }); - - - const response = RouteResponse.redirect(loginAttempt.targetUrl, 303); - - const headers = new Headers(response.headers); - - // Clear login session cookies - const expireAttribs = `Path=/; Max-Age=0; SameSite=Lax; Expires=${new Date(0).toUTCString()}`; - headers.append("Set-Cookie", `login_id=EXPIRED; ${expireAttribs}`); - headers.append("Set-Cookie", `code_verifier=EXPIRED; ${expireAttribs}`); - headers.append("Set-Cookie", `state=EXPIRED; ${expireAttribs}`); - - // Tell the browser to never cache this page - headers.set("Cache-Control", "no-store"); - - // Set token cookie - const cookieAttribs = `Path=/; Max-Age=${expiresIn}; SameSite=Lax; Expires=${expiry.toUTCString()}`; - headers.append("Set-Cookie", `token=${token.token}; ${cookieAttribs}`); + }; + await ctx.modules.tokens.modifyMeta({ token: redirectedFlowToken, newMeta }); - return new Response(response.body, { status: response.status, headers }); + return new RouteResponse( + "You successfully logged in! You can now close this page.", + ); } diff --git a/modules/auth_oauth2/routes/login_link.ts b/modules/auth_oauth2/routes/login_link.ts deleted file mode 100644 index 1b913ab0..00000000 --- a/modules/auth_oauth2/routes/login_link.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - RouteContext, - RuntimeError, - RouteRequest, - RouteResponse, -} from "../module.gen.ts"; - -import { getFullConfig } from "../utils/env.ts"; -import { getClient } from "../utils/client.ts"; -import { generateStateStr } from "../utils/state.ts"; - -// Maybe make different exported functions— `GET`, `POST`, etc? -export async function handle( - ctx: RouteContext, - req: RouteRequest, -): Promise { - // Max 5 login attempts per IP per minute - ctx.modules.rateLimit.throttlePublic({ requests: 5, period: 60 }); - - // Get the data from the RouteRequest query parameters - const url = new URL(req.url); - const provider = url.pathname.split("/").pop(); - if (!provider) throw new RuntimeError( - "invalid_req", - { - statusCode: 400, - meta: { - err: "missing provider at end of URL", - path: url.pathname, - params: Object.fromEntries(url.searchParams.entries()), - }, - }, - ); - - const targetUrl = url.searchParams.get("targetUrl"); - if (!targetUrl) throw new RuntimeError( - "invalid_req", - { - statusCode: 400, - meta: { - err: "missing targetUrl", - path: url.pathname, - params: Object.fromEntries(url.searchParams.entries()), - }, - }, - ); - - console.log({ provider, targetUrl }); - - // Ensure that the provider configurations are valid - const providers = await getFullConfig(ctx.userConfig); - if (!providers) throw new RuntimeError("invalid_config", { statusCode: 500 }); - - // Get the OAuth2 Client and generate a unique state string - const client = getClient(providers, provider, url); - const state = generateStateStr(); - - // Get the URI to eventually redirect the user to - const { uri, codeVerifier } = await client.code.getAuthorizationUri({ state }); - - // Create a login attempt to allow the module to later retrieve the login - // information - const { id: loginId } = await ctx.db.oAuthLoginAttempt.create({ - data: { - provider, - targetUrl, - state, - codeVerifier, - }, - }); - - - // Build the response - const response = RouteResponse.redirect( - uri.toString(), - 303, - ); - - const headers = new Headers(response.headers); - - // Set login session cookies - const cookieOptions = `Path=/; SameSite=Lax; Max-Age=300; Expires=${new Date(Date.now() + 300 * 1000).toUTCString()}`; - headers.append("Set-Cookie", `login_id=${encodeURIComponent(loginId)}; ${cookieOptions}`); - headers.append("Set-Cookie", `code_verifier=${encodeURIComponent(codeVerifier)}; ${cookieOptions}`); - headers.append("Set-Cookie", `state=${encodeURIComponent(state)}; ${cookieOptions}`); - - // Tell the browser to never cache this page - headers.set("Cache-Control", "no-store"); - - return new Response(response.body, { status: response.status, headers }); -} diff --git a/modules/auth_oauth2/scripts/get_login_data.ts b/modules/auth_oauth2/scripts/get_login_data.ts new file mode 100644 index 00000000..28f4913c --- /dev/null +++ b/modules/auth_oauth2/scripts/get_login_data.ts @@ -0,0 +1,41 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; + +import { getFullConfig } from "../utils/env.ts"; +import { getClient } from "../utils/client.ts"; + +import { ProviderIdentifierDetails } from "../utils/types.ts"; +import { tokenToStateStr } from "../utils/state.ts"; + +export interface Request { + flowToken: string; + providerIdent: ProviderIdentifierDetails; +} +export interface Response { + details: { + provider: string; + sub: string; + + retainedTokenDetails: unknown; + } | null; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Max 2 login attempts per IP per minute + // ctx.modules.rateLimit.throttlePublic({ requests: 5, period: 60 }); + + // Add attempt data to the flow token + const { tokens: [{ meta: { oauth } }] } = await ctx.modules.tokens + .fetchByToken({ tokens: [req.flowToken] }); + if (!oauth) return { details: null }; + + const details = { + provider: req.providerIdent.provider, + sub: oauth.sub, + retainedTokenDetails: oauth, + }; + + return { details }; +} diff --git a/modules/auth_oauth2/scripts/init_flow.ts b/modules/auth_oauth2/scripts/init_flow.ts new file mode 100644 index 00000000..2ebcc95d --- /dev/null +++ b/modules/auth_oauth2/scripts/init_flow.ts @@ -0,0 +1,59 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; + +import { getFullConfig } from "../utils/env.ts"; +import { getClient } from "../utils/client.ts"; + +import { ProviderIdentifierDetails } from "../utils/types.ts"; +import { tokenToStateStr } from "../utils/state.ts"; + +export interface Request { + flowToken: string; + providerIdent: ProviderIdentifierDetails; +} +export interface Response { + urlForLoginLink: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Max 2 login attempts per IP per minute + ctx.modules.rateLimit.throttlePublic({ requests: 5, period: 60 }); + + const provider = req.providerIdent.provider; + + // Ensure that the provider configurations are valid + const config = await getFullConfig(ctx.config); + if (!config) throw new RuntimeError("invalid_config", { statusCode: 500 }); + + // Get the OAuth2 Client and generate a unique state string + const client = getClient(config, provider); + const state = await tokenToStateStr(config.oauthSecret, req.flowToken); + + // Get the URI to eventually redirect the user to + const { uri, codeVerifier } = await client.code.getAuthorizationUri({ + state, + }); + + // Add attempt data to the flow token + const { tokens: [{ meta: oldMeta }] } = await ctx.modules.tokens.fetchByToken( + { tokens: [req.flowToken] }, + ); + const newMeta = { + ...oldMeta, + oauthData: { + provider, + state, + codeVerifier, + }, + }; + await ctx.modules.tokens.modifyMeta({ + token: req.flowToken, + newMeta, + }); + + return { + urlForLoginLink: uri.toString(), + }; +} diff --git a/modules/auth_oauth2/utils/client.ts b/modules/auth_oauth2/utils/client.ts index 2e0bedf0..f8643c86 100644 --- a/modules/auth_oauth2/utils/client.ts +++ b/modules/auth_oauth2/utils/client.ts @@ -2,11 +2,17 @@ import { OAuth2Client } from "https://deno.land/x/oauth2_client@v1.0.2/mod.ts"; import { FullConfig, ProviderConfig } from "./env.ts"; import { RuntimeError } from "../module.gen.ts"; -export function getClient(cfg: FullConfig, provider: string, uri: URL) { +export function getClient(cfg: FullConfig, provider: string) { + console.log({ provider }); const providerCfg = cfg.providers[provider]; - if (!providerCfg) throw new RuntimeError("invalid_provider", { statusCode: 400 }); + if (!providerCfg) { + throw new RuntimeError("invalid_provider", { statusCode: 400 }); + } - const redirectUri = new URL(`./modules/auth_oauth2/route/callback/${provider}`, uri.origin).toString(); + // TODO: Remove hardcoded origin + const redirectUri = new URL( + `http://localhost:6420/modules/auth_oauth2/route/callback/${provider}`, + ).toString(); return new OAuth2Client({ clientId: providerCfg.clientId, @@ -21,14 +27,19 @@ export function getClient(cfg: FullConfig, provider: string, uri: URL) { }); } -export async function getUserUniqueIdentifier(accessToken: string, provider: ProviderConfig): Promise { +export async function getUserUniqueIdentifier( + accessToken: string, + provider: ProviderConfig, +): Promise { const res = await fetch(provider.endpoints.userinfo, { headers: { Authorization: `Bearer ${accessToken}`, }, }); - if (!res.ok) throw new RuntimeError("bad_oauth_response", { statusCode: 502 }); + if (!res.ok) { + throw new RuntimeError("bad_oauth_response", { statusCode: 502 }); + } let json: unknown; try { @@ -48,7 +59,9 @@ export async function getUserUniqueIdentifier(accessToken: string, provider: Pro console.warn("Invalid userinfo response", jsonObj); throw new RuntimeError("bad_oauth_response", { statusCode: 502 }); } - if (!uniqueIdent) throw new RuntimeError("bad_oauth_response", { statusCode: 502 }); + if (!uniqueIdent) { + throw new RuntimeError("bad_oauth_response", { statusCode: 502 }); + } return uniqueIdent.toString(); } diff --git a/modules/auth_oauth2/utils/env.ts b/modules/auth_oauth2/utils/env.ts index a5490143..18d0d2d9 100644 --- a/modules/auth_oauth2/utils/env.ts +++ b/modules/auth_oauth2/utils/env.ts @@ -2,54 +2,59 @@ import { Config, ProviderEndpoints } from "../config.ts"; import { getFromOidcWellKnown } from "./wellknown.ts"; export interface FullConfig { - providers: Record; - oauthSecret: string; + providers: Record; + oauthSecret: string; } export interface ProviderConfig { name: string; - clientId: string; - clientSecret: string; - endpoints: ProviderEndpoints; + clientId: string; + clientSecret: string; + endpoints: ProviderEndpoints; } -export async function getProvidersEnvConfig(providerCfg: Config["providers"]): Promise { - const baseProviders = Object.entries(providerCfg).map(([name, config]) => ({ name, config })); - - const providers: ProviderConfig[] = []; - for (const { name, config } of baseProviders) { - const clientIdEnv = `${name.toUpperCase()}_OAUTH_CLIENT_ID`; - const clientSecretEnv = `${name.toUpperCase()}_OAUTH_CLIENT_SECRET`; - - const clientId = Deno.env.get(clientIdEnv); - const clientSecret = Deno.env.get(clientSecretEnv); - if (!clientId || !clientSecret) return null; - - let resolvedConfig: ProviderEndpoints; - if (typeof config === "string") { - resolvedConfig = await getFromOidcWellKnown(config); - } else { - resolvedConfig = config; - } - - providers.push({ name, clientId, clientSecret, endpoints: resolvedConfig }); - } - - return providers; +export async function getProvidersEnvConfig( + providerCfg: Config["providers"], +): Promise { + const baseProviders = Object.entries(providerCfg).map(([name, config]) => ({ + name, + config, + })); + + const providers: ProviderConfig[] = []; + for (const { name, config } of baseProviders) { + const clientIdEnv = `${name.toUpperCase()}_OAUTH_CLIENT_ID`; + const clientSecretEnv = `${name.toUpperCase()}_OAUTH_CLIENT_SECRET`; + + const clientId = Deno.env.get(clientIdEnv); + const clientSecret = Deno.env.get(clientSecretEnv); + if (!clientId || !clientSecret) return null; + + let resolvedConfig: ProviderEndpoints; + if (typeof config === "string") { + resolvedConfig = await getFromOidcWellKnown(config); + } else { + resolvedConfig = config; + } + + providers.push({ name, clientId, clientSecret, endpoints: resolvedConfig }); + } + + return providers; } export function getOauthSecret(): string | null { - return Deno.env.get("OAUTH_SECRET") ?? null; + return Deno.env.get("OAUTH_SECRET") ?? null; } export async function getFullConfig(cfg: Config): Promise { - const providerArr = await getProvidersEnvConfig(cfg.providers); - if (!providerArr) return null; + const providerArr = await getProvidersEnvConfig(cfg.providers); + if (!providerArr) return null; - const providers = Object.fromEntries(providerArr.map(p => [p.name, p])); + const providers = Object.fromEntries(providerArr.map((p) => [p.name, p])); - const oauthSecret = getOauthSecret(); - if (!oauthSecret) return null; + const oauthSecret = getOauthSecret(); + if (!oauthSecret) return null; - return { providers, oauthSecret }; + return { providers, oauthSecret }; } diff --git a/modules/auth_oauth2/utils/state.ts b/modules/auth_oauth2/utils/state.ts index 40a0f198..6c025f88 100644 --- a/modules/auth_oauth2/utils/state.ts +++ b/modules/auth_oauth2/utils/state.ts @@ -1,46 +1,97 @@ import base64 from "https://deno.land/x/b64@1.1.28/src/base64.js"; -const STATE_BYTES = 16; +type InputData = ArrayBufferLike | Uint8Array | string; +async function secretToKey(secret: string) { + const secretDigest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(secret), + ); + return await crypto.subtle.importKey( + "raw", + secretDigest, + "AES-GCM", + false, + ["encrypt", "decrypt"], + ); +} -type InputData = ArrayBufferLike | Uint8Array | string; +export async function tokenToState( + oauthSecret: string, + flowToken: string, +): Promise { + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const encodedToken = new TextEncoder().encode(flowToken); + const oauthSecretKey = await secretToKey(oauthSecret); -/** - * Generates a new random `STATE_BYTES`-byte state buffer. - * - * @returns A new random state buffer - */ -export function generateState(): ArrayBufferLike { - return crypto.getRandomValues(new Uint8Array(STATE_BYTES)); + const ciphertext = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: nonce, + additionalData: new Uint8Array(0), + tagLength: 128, + }, + oauthSecretKey, + encodedToken, + ); + + const state = new Uint8Array(nonce.length + ciphertext.byteLength); + state.set(nonce); + state.set(new Uint8Array(ciphertext), nonce.length); + + return state.buffer; } -/** - * Generates a new random string with `STATE_BYTES` bytes of entropy. - */ -export function generateStateStr(): string { - return base64.fromArrayBuffer(generateState()); +export async function tokenToStateStr( + oauthSecret: string, + flowToken: string, +): Promise { + return base64.fromArrayBuffer(await tokenToState(oauthSecret, flowToken)); +} + +export async function extractTokenFromState( + oauthSecret: string, + state: string, +): Promise { + const stateBuf = base64.toArrayBuffer(state); + const nonce = stateBuf.slice(0, 12); + const ciphertext = stateBuf.slice(12); + + const oauthSecretKey = await secretToKey(oauthSecret); + const token = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: nonce, + additionalData: new Uint8Array(0), + tagLength: 128, + }, + oauthSecretKey, + ciphertext, + ); + + return new TextDecoder().decode(token); } /** * Compares two buffers for equality in a way that is resistant to timing * attacks. - * + * * @param a The first buffer * @param b The second buffer * @returns Whether the two buffers are equal */ export function compareConstantTime(a: InputData, b: InputData): boolean { - const bufLikeA = typeof a === "string" ? new TextEncoder().encode(a) : a; - const bufLikeB = typeof b === "string" ? new TextEncoder().encode(b) : b; + const bufLikeA = typeof a === "string" ? new TextEncoder().encode(a) : a; + const bufLikeB = typeof b === "string" ? new TextEncoder().encode(b) : b; - if (bufLikeA.byteLength !== bufLikeB.byteLength) return false; + if (bufLikeA.byteLength !== bufLikeB.byteLength) return false; - const bufA = new Uint8Array(bufLikeA); - const bufB = new Uint8Array(bufLikeB); + const bufA = new Uint8Array(bufLikeA); + const bufB = new Uint8Array(bufLikeB); - let result = 0; - for (let i = 0; i < bufLikeA.byteLength; i++) { - result |= bufA[i] ^ bufB[i]; - } - return result === 0; + let result = 0; + for (let i = 0; i < bufLikeA.byteLength; i++) { + result |= bufA[i] ^ bufB[i]; + } + return result === 0; } diff --git a/modules/auth_oauth2/utils/trace.ts b/modules/auth_oauth2/utils/trace.ts deleted file mode 100644 index 41dd4513..00000000 --- a/modules/auth_oauth2/utils/trace.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ModuleContext } from "../module.gen.ts"; - -export function getHttpPath(ctx: T): string | undefined { - for (const entry of ctx.trace.entries) { - if ("httpRequest" in entry.type) { - return entry.type.httpRequest.path; - } - } - return undefined; -} - -export function getCookieString(ctx: T): string | undefined { - for (const entry of ctx.trace.entries) { - if ("httpRequest" in entry.type) { - return entry.type.httpRequest.headers["cookie"]; - } - } - return undefined; -} - -export function getCookieObject(ctx: T): Record | null { - const cookieString = getCookieString(ctx); - if (!cookieString) return null; - - const pairs = cookieString - .split(";") - .map(pair => pair.trim()) - .map(pair => pair.split("=")) - .map(([key, value]) => [decodeURIComponent(key), decodeURIComponent(value)]); - - return Object.fromEntries(pairs); -} - - -export function getLoginIdFromCookie(ctx: T): string | null { - const cookies = getCookieObject(ctx); - if (!cookies) return null; - return cookies["login_id"] || null; -} - -export function getCodeVerifierFromCookie(ctx: T): string | null { - const cookies = getCookieObject(ctx); - if (!cookies) return null; - return cookies["code_verifier"] || null; -} - -export function getStateFromCookie(ctx: T): string | null { - const cookies = getCookieObject(ctx); - if (!cookies) return null; - return cookies["state"] || null; -} diff --git a/modules/auth_oauth2/utils/types.ts b/modules/auth_oauth2/utils/types.ts new file mode 100644 index 00000000..a8c56507 --- /dev/null +++ b/modules/auth_oauth2/utils/types.ts @@ -0,0 +1,3 @@ +export interface ProviderIdentifierDetails { + provider: string; +} diff --git a/modules/auth_oauth2/utils/wellknown.ts b/modules/auth_oauth2/utils/wellknown.ts index 834fe3b0..400f4d11 100644 --- a/modules/auth_oauth2/utils/wellknown.ts +++ b/modules/auth_oauth2/utils/wellknown.ts @@ -3,38 +3,55 @@ import { ProviderEndpoints } from "../config.ts"; /** * Get the OIDC well-known config object from the given URL. - * + * * @param wellKnownUrl The URL of the OIDC well-known config * @returns The OIDC well-known config object */ -export async function getFromOidcWellKnown(wellKnownUrl: string): Promise { - const res = await fetch(wellKnownUrl).catch(() => { throw new RuntimeError("invalid_config") }); - if (!res.ok) throw new RuntimeError("invalid_config"); +export async function getFromOidcWellKnown( + wellKnownUrl: string, +): Promise { + const res = await fetch(wellKnownUrl).catch(() => { + throw new RuntimeError("invalid_config"); + }); + if (!res.ok) throw new RuntimeError("invalid_config"); - const json: unknown = await res.json().catch(() => { throw new RuntimeError("invalid_config") }); - if (typeof json !== "object" || json === null) throw new RuntimeError("invalid_config"); + const json: unknown = await res.json().catch(() => { + throw new RuntimeError("invalid_config"); + }); + if (typeof json !== "object" || json === null) { + throw new RuntimeError("invalid_config"); + } - const jsonObj = json as Record; + const jsonObj = json as Record; - const { - authorization_endpoint, - token_endpoint, - userinfo_endpoint, - scopes_supported, - } = jsonObj; + const { + authorization_endpoint, + token_endpoint, + userinfo_endpoint, + scopes_supported, + } = jsonObj; - if (typeof authorization_endpoint !== "string") throw new RuntimeError("invalid_config"); - if (typeof token_endpoint !== "string") throw new RuntimeError("invalid_config"); - if (typeof userinfo_endpoint !== "string") throw new RuntimeError("invalid_config"); - if (!Array.isArray(scopes_supported)) throw new RuntimeError("invalid_config"); - if (scopes_supported.some(scope => typeof scope !== "string")) throw new RuntimeError("invalid_config"); + if (typeof authorization_endpoint !== "string") { + throw new RuntimeError("invalid_config"); + } + if (typeof token_endpoint !== "string") { + throw new RuntimeError("invalid_config"); + } + if (typeof userinfo_endpoint !== "string") { + throw new RuntimeError("invalid_config"); + } + if (!Array.isArray(scopes_supported)) { + throw new RuntimeError("invalid_config"); + } + if (scopes_supported.some((scope) => typeof scope !== "string")) { + throw new RuntimeError("invalid_config"); + } - - return { - authorization: authorization_endpoint, - token: token_endpoint, - userinfo: userinfo_endpoint, - scopes: scopes_supported.join(" "), - userinfoKey: "sub", - }; + return { + authorization: authorization_endpoint, + token: token_endpoint, + userinfo: userinfo_endpoint, + scopes: scopes_supported.join(" "), + userinfoKey: "sub", + }; } diff --git a/modules/tokens/scripts/revoke.ts b/modules/tokens/scripts/revoke.ts index cb4d357f..40f2a743 100644 --- a/modules/tokens/scripts/revoke.ts +++ b/modules/tokens/scripts/revoke.ts @@ -26,7 +26,7 @@ export async function run( // Sets revokedAt on all tokens that have not already been revoked. Returns // wether or not each token was revoked. const rows = await ctx.db.$queryRawUnsafe( - ` + ` WITH "PreUpdate" AS ( SELECT "id", "revokedAt" FROM "${ctx.dbSchema}"."Token" @@ -38,8 +38,8 @@ export async function run( WHERE "Token"."id" = "PreUpdate"."id" RETURNING "Token"."id" AS "id", "PreUpdate"."revokedAt" IS NOT NULL AS "alreadyRevoked" `, - req.tokenIds, - ); + req.tokenIds, + ); const updates: Record = {}; for (const tokenId of req.tokenIds) { diff --git a/modules/users/scripts/authenticate_token.ts b/modules/users/scripts/authenticate_token.ts index e2e18269..1d31558e 100644 --- a/modules/users/scripts/authenticate_token.ts +++ b/modules/users/scripts/authenticate_token.ts @@ -4,12 +4,12 @@ import { User } from "../utils/types.ts"; export interface Request { userToken: string; - fetchUser?: boolean; + fetchUser?: boolean; } export interface Response { - userId: string; - user?: User; + userId: string; + user?: User; } export async function run( @@ -24,13 +24,13 @@ export async function run( if (token.type !== "user") throw new RuntimeError("token_not_user_token"); const userId = token.meta.userId; - let user; - if (req.fetchUser) { - user = await ctx.db.user.findFirstOrThrow({ - where: { id: userId }, - }); + let user; + if (req.fetchUser) { + user = await ctx.db.user.findFirstOrThrow({ + where: { id: userId }, + }); - } + } return { userId, user }; } diff --git a/tests/basic/backend.json b/tests/basic/backend.json index 6ce20409..85c2f00a 100644 --- a/tests/basic/backend.json +++ b/tests/basic/backend.json @@ -33,9 +33,9 @@ "auth": { "registry": "local", "config": { - "email": { - "fromEmail": "hello@rivet.gg" - } + "providers": [ + { "oauth": { "provider": "google" } } + ] } }, "email": { @@ -50,15 +50,7 @@ "registry": "local", "config": { "providers": { - "google": "https://accounts.google.com/.well-known/openid-configuration", - "microsoft": "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration", - "github": { - "authorization": "https://github.com/login/oauth/authorize", - "token": "https://github.com/login/oauth/access_token", - "userinfo": "https://api.github.com/user", - "scope": ["read:user"], - "userinfoKey": "id" - } + "google": "https://accounts.google.com/.well-known/openid-configuration" } } } diff --git a/tests/basic/deno.lock b/tests/basic/deno.lock index 2e0bc6e3..461f3ab3 100644 --- a/tests/basic/deno.lock +++ b/tests/basic/deno.lock @@ -117,6 +117,7 @@ "https://esm.sh/ajv@^8.12.0": "https://esm.sh/ajv@8.12.0" }, "remote": { + "https://deno.land/std@0.161.0/encoding/base64.ts": "c57868ca7fa2fbe919f57f88a623ad34e3d970d675bdc1ff3a9d02bba7409db2", "https://deno.land/std@0.208.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", "https://deno.land/std@0.208.0/assert/_diff.ts": "58e1461cc61d8eb1eacbf2a010932bf6a05b79344b02ca38095f9b805795dc48", "https://deno.land/std@0.208.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", @@ -214,6 +215,7 @@ "https://deno.land/std@0.220.0/assert/unimplemented.ts": "47ca67d1c6dc53abd0bd729b71a31e0825fc452dbcd4fde4ca06789d5644e7fd", "https://deno.land/std@0.220.0/assert/unreachable.ts": "3670816a4ab3214349acb6730e3e6f5299021234657eefe05b48092f3848c270", "https://deno.land/std@0.220.0/fmt/colors.ts": "d239d84620b921ea520125d778947881f62c50e78deef2657073840b8af9559a", + "https://deno.land/x/b64@1.1.28/src/base64.js": "c81768c67f6f461b01d10ec24c6c4da71e2f12b3c96e32c62146c98c69685101", "https://deno.land/x/deno_faker@v1.0.3/lib/address.ts": "d461912c0a8c14fb6d277016e4e2e0098fcba4dee0fe77f5de248c7fc2aaa601", "https://deno.land/x/deno_faker@v1.0.3/lib/commerce.ts": "797e10dd360b1f63b2d877b368db5bedabb90c07d5ccb4cc63fded644648c8b5", "https://deno.land/x/deno_faker@v1.0.3/lib/company.ts": "c241dd2ccfcee7a400b94badcdb5ee9657784dd47a86417b54952913023cbd11", @@ -1452,6 +1454,17 @@ "https://deno.land/x/deno_faker@v1.0.3/vendor/mersenne.ts": "8a61935ca2f91b925d9e8cf262eaf8b3277d091f791c8b4f93f995359db1a9a7", "https://deno.land/x/deno_faker@v1.0.3/vendor/unique.ts": "b8bb044d4caf0bb1a868bd26839bb5822e2013e8385f119db7029631e5a53e0b", "https://deno.land/x/deno_faker@v1.0.3/vendor/user-agent.ts": "b95c7bda4ad37ba25b60c4431227361eabba70db14456abb69227d6536ea93fb", + "https://deno.land/x/oauth2_client@v1.0.2/mod.ts": "ea54c0a894d3303a80552ca65835b5b104d16415343b24e191f08e7f5db90ff7", + "https://deno.land/x/oauth2_client@v1.0.2/src/authorization_code_grant.ts": "36953750b75fb0a14fbf4e0e4bcc1d5ae0209d216d7b32f93a134b035ecf3d25", + "https://deno.land/x/oauth2_client@v1.0.2/src/client_credentials_grant.ts": "5bb9869925c5f5d11e8d66a86da37e2353107d57f57ec3a1480e197462e79be5", + "https://deno.land/x/oauth2_client@v1.0.2/src/errors.ts": "7603479b80386b5cc7e384c2af5f5262ed7c2123e4e297d9f21e95515f8a803a", + "https://deno.land/x/oauth2_client@v1.0.2/src/grant_base.ts": "86ae9eb3495f2304a634498fbb83741c5dc0e1357e02c40e12e212de5e9750f7", + "https://deno.land/x/oauth2_client@v1.0.2/src/implicit_grant.ts": "d5359aebbdaaff039c0d078890aa4ffa2869da19c521e535e15caf09c069e6b8", + "https://deno.land/x/oauth2_client@v1.0.2/src/oauth2_client.ts": "4e5ec26676661a3f69544826a4c27b30cc07dfcfc77f86981c324aaa53291a11", + "https://deno.land/x/oauth2_client@v1.0.2/src/pkce.ts": "d286a087cc8ef985b71a2bf391e9e9d86a78ac6d93e30c46e73006171aed0986", + "https://deno.land/x/oauth2_client@v1.0.2/src/refresh_token_grant.ts": "22cb1598e48fb037b4111a446573f7b48a3b361b58de58af17ba097221b12b54", + "https://deno.land/x/oauth2_client@v1.0.2/src/resource_owner_password_credentials.ts": "bd3df99d32eeebffb411c4a2d3c3d057395515fb41690a8d91460dd74b9bf466", + "https://deno.land/x/oauth2_client@v1.0.2/src/types.ts": "3327c2e81bc483e91843fb103595dd304393c3ac2a530d1c89200b6a5cf75e13", "https://esm.sh/@aws-sdk/client-s3@3.592.0": "6410aa6af828586a1fea0ad023479483b5844a15054bd62a77f8c1e1f467e54a", "https://esm.sh/@aws-sdk/s3-request-presigner@3.592.0": "41615b3a8cdd935bae991dbff554dd0f8765cf591fef33072a5338e6a7576814", "https://esm.sh/ajv-formats@2.1.1": "575b3830618970ddc3aba96310bf4df7358bb37fcea101f58b36897ff3ac2ea7",