From d8ad2c8dfd822c9f128bd652d1bc81c7bbd638f6 Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Sat, 20 Jul 2024 13:34:32 -0400 Subject: [PATCH 1/2] eat: Create the `auth_username_pasword` module --- modules/auth_username_password/tests/e2e.ts | 4 ++++ modules/users/scripts/fetch_by_uname.ts | 24 +++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 modules/users/scripts/fetch_by_uname.ts diff --git a/modules/auth_username_password/tests/e2e.ts b/modules/auth_username_password/tests/e2e.ts index 64ba9651..1c090b75 100644 --- a/modules/auth_username_password/tests/e2e.ts +++ b/modules/auth_username_password/tests/e2e.ts @@ -55,7 +55,11 @@ test("test_sign_in", async (ctx: TestContext) => { userToken: token.token, }); +<<<<<<< HEAD const { users: [user] } = await ctx.modules.users.fetchByUsername({ +======= + const { users: [user] } = await ctx.modules.users.fetchByUname({ +>>>>>>> 7d761a9 (eat: Create the `auth_username_pasword` module) usernames: [username], }); diff --git a/modules/users/scripts/fetch_by_uname.ts b/modules/users/scripts/fetch_by_uname.ts new file mode 100644 index 00000000..98acba09 --- /dev/null +++ b/modules/users/scripts/fetch_by_uname.ts @@ -0,0 +1,24 @@ +import { ScriptContext } from "../module.gen.ts"; +import { User } from "../utils/types.ts"; + +export interface Request { + usernames: string[]; +} + +export interface Response { + users: User[]; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + + const users = await ctx.db.user.findMany({ + where: { username: { in: req.usernames } }, + orderBy: { username: "desc" }, + }); + + return { users }; +} From c9ea5ef98a673fa540faee7257cf3f09453f51d0 Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Fri, 10 May 2024 08:17:08 -0400 Subject: [PATCH 2/2] feat: Create an OAuth2 module for authenticating users --- modules/auth_oauth2/config.ts | 11 ++ .../20240701041159_init/migration.sql | 15 ++ .../db/migrations/migration_lock.toml | 3 + modules/auth_oauth2/db/schema.prisma | 21 +++ modules/auth_oauth2/module.json | 74 ++++++++ modules/auth_oauth2/routes/login_callback.ts | 106 ++++++++++++ .../scripts/complete_add_to_user.ts | 64 +++++++ .../scripts/complete_login_to_user.ts | 60 +++++++ .../auth_oauth2/scripts/get_login_status.ts | 45 +++++ modules/auth_oauth2/scripts/start_login.ts | 69 ++++++++ modules/auth_oauth2/utils/client.ts | 57 +++++++ modules/auth_oauth2/utils/env.ts | 55 ++++++ modules/auth_oauth2/utils/pages.ts | 14 ++ modules/auth_oauth2/utils/state.ts | 161 ++++++++++++++++++ modules/auth_oauth2/utils/trace.ts | 51 ++++++ modules/auth_oauth2/utils/wellknown.ts | 40 +++++ modules/auth_username_password/tests/e2e.ts | 4 - modules/users/scripts/fetch_by_uname.ts | 24 --- 18 files changed, 846 insertions(+), 28 deletions(-) create mode 100644 modules/auth_oauth2/config.ts create mode 100644 modules/auth_oauth2/db/migrations/20240701041159_init/migration.sql create mode 100644 modules/auth_oauth2/db/migrations/migration_lock.toml create mode 100644 modules/auth_oauth2/db/schema.prisma create mode 100644 modules/auth_oauth2/module.json create mode 100644 modules/auth_oauth2/routes/login_callback.ts create mode 100644 modules/auth_oauth2/scripts/complete_add_to_user.ts create mode 100644 modules/auth_oauth2/scripts/complete_login_to_user.ts create mode 100644 modules/auth_oauth2/scripts/get_login_status.ts create mode 100644 modules/auth_oauth2/scripts/start_login.ts create mode 100644 modules/auth_oauth2/utils/client.ts create mode 100644 modules/auth_oauth2/utils/env.ts create mode 100644 modules/auth_oauth2/utils/pages.ts create mode 100644 modules/auth_oauth2/utils/state.ts create mode 100644 modules/auth_oauth2/utils/trace.ts create mode 100644 modules/auth_oauth2/utils/wellknown.ts delete mode 100644 modules/users/scripts/fetch_by_uname.ts diff --git a/modules/auth_oauth2/config.ts b/modules/auth_oauth2/config.ts new file mode 100644 index 00000000..76e61b58 --- /dev/null +++ b/modules/auth_oauth2/config.ts @@ -0,0 +1,11 @@ +export interface Config { + providers: Record; +} + +export interface ProviderEndpoints { + authorization: string; + token: string; + userinfo: string; + scopes: string; + userinfoKey: string; +} diff --git a/modules/auth_oauth2/db/migrations/20240701041159_init/migration.sql b/modules/auth_oauth2/db/migrations/20240701041159_init/migration.sql new file mode 100644 index 00000000..be1b8067 --- /dev/null +++ b/modules/auth_oauth2/db/migrations/20240701041159_init/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "LoginAttempts" ( + "id" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "state" TEXT NOT NULL, + "codeVerifier" TEXT NOT NULL, + "identifier" TEXT, + "tokenData" JSONB, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "invalidatedAt" TIMESTAMP(3), + + CONSTRAINT "LoginAttempts_pkey" PRIMARY KEY ("id") +); diff --git a/modules/auth_oauth2/db/migrations/migration_lock.toml b/modules/auth_oauth2/db/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/modules/auth_oauth2/db/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/modules/auth_oauth2/db/schema.prisma b/modules/auth_oauth2/db/schema.prisma new file mode 100644 index 00000000..b3a431c2 --- /dev/null +++ b/modules/auth_oauth2/db/schema.prisma @@ -0,0 +1,21 @@ +// Do not modify this `datasource` block +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model LoginAttempts { + id String @id @default(uuid()) + + providerId String + state String + codeVerifier String + + identifier String? + tokenData Json? + + startedAt DateTime @default(now()) + expiresAt DateTime + completedAt DateTime? + invalidatedAt DateTime? +} diff --git a/modules/auth_oauth2/module.json b/modules/auth_oauth2/module.json new file mode 100644 index 00000000..c374344e --- /dev/null +++ b/modules/auth_oauth2/module.json @@ -0,0 +1,74 @@ +{ + "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": {}, + "identities": {}, + "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": { + "start_login": { + "name": "Start Login", + "description": "Start the OAuth login process. Returns a URL to redirect the user to and a flow token.", + "public": true + }, + "get_login_status": { + "name": "Get Login Status", + "description": "Check the status of a OAuth login using the flow token. Returns the status of the login flow.", + "public": true + }, + "complete_add_to_user": { + "name": "Complete Flow and Add OAuth Login to User", + "description": "Use a finished OAuth flow to add the OAuth login to an already-authenticated users.", + "public": true + }, + "complete_login_to_user": { + "name": "Complete Flow and Login to or Create User with OAuth", + "description": "Use a finished OAuth flow to login to a user, creating a new one if it doesn't exist.", + "public": true + } + }, + "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 diff --git a/modules/auth_oauth2/routes/login_callback.ts b/modules/auth_oauth2/routes/login_callback.ts new file mode 100644 index 00000000..47427862 --- /dev/null +++ b/modules/auth_oauth2/routes/login_callback.ts @@ -0,0 +1,106 @@ +import { + RouteContext, + RuntimeError, + RouteRequest, + RouteResponse, +} from "../module.gen.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 { compareConstantTime, stateToDataStr } from "../utils/state.ts"; +import { OAUTH_DONE_HTML } from "../utils/pages.ts"; + +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 }); + + // Ensure that the provider configurations are valid + const config = await getFullConfig(ctx.config); + if (!config) throw new RuntimeError("invalid_config", { statusCode: 500 }); + + // 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 }); + } + + // Extract the data from the state + const stateData = await stateToDataStr(config.oauthSecret, redirectedState); + const { flowId, providerId } = JSON.parse(stateData); + + // Get the login attempt stored in the database + const loginAttempt = await ctx.db.loginAttempts.findUnique({ + where: { + id: flowId, + }, + }); + if (!loginAttempt) throw new RuntimeError("login_not_found", { statusCode: 400 }); + + // Check if the login attempt is valid + if (loginAttempt.completedAt) { + throw new RuntimeError("login_already_completed", { statusCode: 400 }); + } + if (loginAttempt.invalidatedAt) { + throw new RuntimeError("login_cancelled", { statusCode: 400 }); + } + if (new Date(loginAttempt.expiresAt) < new Date()) { + throw new RuntimeError("login_expired", { statusCode: 400 }); + } + + // Check if the provider ID and state match + const providerIdMatch = compareConstantTime(loginAttempt.providerId, providerId); + const stateMatch = compareConstantTime(loginAttempt.state, redirectedState); + if (!providerIdMatch || !stateMatch) throw new RuntimeError("invalid_state", { statusCode: 400 }); + + const { state, codeVerifier } = loginAttempt; + + // Get the provider config + const provider = config.providers[providerId]; + if (!provider) throw new RuntimeError("invalid_provider", { statusCode: 400 }); + + // Get the oauth client + const client = getClient(config, provider.name); + if (!client.config.redirectUri) throw new RuntimeError("invalid_config", { statusCode: 500 }); + + // Get the user's tokens and sub + let tokens: Tokens; + let ident: string; + try { + tokens = await client.code.getToken(uri.toString(), { state, codeVerifier }); + ident = await getUserUniqueIdentifier(tokens.accessToken, provider); + } catch (e) { + console.error(e); + throw new RuntimeError("invalid_oauth_response", { statusCode: 502 }); + } + + // Update the login attempt + await ctx.db.loginAttempts.update({ + where: { + id: flowId, + }, + data: { + identifier: ident, + tokenData: { ...tokens }, + completedAt: new Date(), + }, + }); + + return new RouteResponse( + OAUTH_DONE_HTML, + { + status: 200, + headers: { + "Content-Type": "text/html", + }, + }, + ); +} diff --git a/modules/auth_oauth2/scripts/complete_add_to_user.ts b/modules/auth_oauth2/scripts/complete_add_to_user.ts new file mode 100644 index 00000000..bb1ce3ed --- /dev/null +++ b/modules/auth_oauth2/scripts/complete_add_to_user.ts @@ -0,0 +1,64 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; + +export interface Request { + flowToken: string; + userToken: string; +} + +export type Response = ReturnType; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + + if (!req.flowToken) throw new RuntimeError("missing_token", { statusCode: 400 }); + + const { tokens: [flowToken] } = await ctx.modules.tokens.fetchByToken({ tokens: [req.flowToken] }); + if (!flowToken) { + throw new RuntimeError("invalid_token", { statusCode: 400 }); + } + if (new Date(flowToken.expireAt ?? 0) < new Date()) { + throw new RuntimeError("expired_token", { statusCode: 400 }); + } + + const flowId = flowToken.meta.flowId; + if (!flowId) throw new RuntimeError("invalid_token", { statusCode: 400 }); + + const flow = await ctx.db.loginAttempts.findFirst({ + where: { + id: flowId, + } + }); + if (!flow) throw new RuntimeError("invalid_token", { statusCode: 400 }); + + if (!flow.identifier || !flow.tokenData) { + throw new RuntimeError("flow_not_complete", { statusCode: 400 }); + } + + await ctx.modules.users.authenticateToken({ userToken: req.userToken }); + + const tokenData = flow.tokenData; + if (!tokenData) { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + if (typeof tokenData !== "object") { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + if (Array.isArray(tokenData)) { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + + return await ctx.modules.identities.link({ + userToken: req.userToken, + info: { + identityType: "oauth2", + identityId: flow.providerId, + }, + uniqueData: { + identifier: flow.identifier, + }, + additionalData: tokenData, + }); +} diff --git a/modules/auth_oauth2/scripts/complete_login_to_user.ts b/modules/auth_oauth2/scripts/complete_login_to_user.ts new file mode 100644 index 00000000..edc1c5eb --- /dev/null +++ b/modules/auth_oauth2/scripts/complete_login_to_user.ts @@ -0,0 +1,60 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; + +export interface Request { + flowToken: string; +} + +export type Response = ReturnType; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + + if (!req.flowToken) throw new RuntimeError("missing_token", { statusCode: 400 }); + + const { tokens: [flowToken] } = await ctx.modules.tokens.fetchByToken({ tokens: [req.flowToken] }); + if (!flowToken) { + throw new RuntimeError("invalid_token", { statusCode: 400 }); + } + if (new Date(flowToken.expireAt ?? 0) < new Date()) { + throw new RuntimeError("expired_token", { statusCode: 400 }); + } + + const flowId = flowToken.meta.flowId; + if (!flowId) throw new RuntimeError("invalid_token", { statusCode: 400 }); + + const flow = await ctx.db.loginAttempts.findFirst({ + where: { + id: flowId, + } + }); + if (!flow) throw new RuntimeError("invalid_token", { statusCode: 400 }); + + if (!flow.identifier || !flow.tokenData) { + throw new RuntimeError("flow_not_complete", { statusCode: 400 }); + } + + const tokenData = flow.tokenData; + if (!tokenData) { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + if (typeof tokenData !== "object") { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + if (Array.isArray(tokenData)) { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + + return await ctx.modules.identities.signInOrSignUp({ + info: { + identityType: "oauth2", + identityId: flow.providerId, + }, + uniqueData: { + identifier: flow.identifier, + }, + additionalData: tokenData, + }); +} diff --git a/modules/auth_oauth2/scripts/get_login_status.ts b/modules/auth_oauth2/scripts/get_login_status.ts new file mode 100644 index 00000000..2658f495 --- /dev/null +++ b/modules/auth_oauth2/scripts/get_login_status.ts @@ -0,0 +1,45 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; + +export interface Request { + flowToken: string; +} + +export interface Response { + status: "complete" | "pending" | "expired" | "cancelled"; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Intended to be once per second plus some leeway + await ctx.modules.rateLimit.throttlePublic({ + requests: 11, + period: 10, + }); + + if (!req.flowToken) throw new RuntimeError("missing_token", { statusCode: 400 }); + const { tokens: [flowToken] } = await ctx.modules.tokens.fetchByToken({ tokens: [req.flowToken] }); + if (!flowToken) throw new RuntimeError("invalid_token", { statusCode: 400 }); + if (new Date(flowToken.expireAt ?? 0) < new Date()) return { status: "expired" }; + + const flowId = flowToken.meta.flowId; + if (!flowId) throw new RuntimeError("invalid_token", { statusCode: 400 }); + + const flow = await ctx.db.loginAttempts.findFirst({ + where: { + id: flowId, + } + }); + if (!flow) throw new RuntimeError("invalid_token", { statusCode: 400 }); + + if (flow.identifier && flow.tokenData) { + return { status: "complete" }; + } else if (new Date(flow.expiresAt) < new Date()) { + return { status: "expired" }; + } else if (flow.invalidatedAt) { + return { status: "cancelled" }; + } else { + return { status: "pending" }; + } +} diff --git a/modules/auth_oauth2/scripts/start_login.ts b/modules/auth_oauth2/scripts/start_login.ts new file mode 100644 index 00000000..dcac7dcb --- /dev/null +++ b/modules/auth_oauth2/scripts/start_login.ts @@ -0,0 +1,69 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { getClient } from "../utils/client.ts"; +import { getFullConfig } from "../utils/env.ts"; +import { dataToStateStr } from "../utils/state.ts"; + +const FLOW_LIFE_SECS = 60 * 30; // 30 minutes +function getExpiry(): Date { + const expiresAt = Date.now() + FLOW_LIFE_SECS * 1000; + return new Date(expiresAt); +} + +export interface Request { + provider: string; +} + +export interface Response { + authUrl: string; + flowToken: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + + // 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 + const client = getClient(config, req.provider); + + // Create a flow token to authenticate the login attempt + const flowId = crypto.randomUUID(); + const expiry = getExpiry(); + const { token: { token: flowToken } } = await ctx.modules.tokens.create({ + type: "auth_oauth2_flow", + expireAt: expiry.toISOString(), + meta: { + flowId, + } + }); + + // Generate a random state string + const state = await dataToStateStr( + config.oauthSecret, + JSON.stringify({ flowId, providerId: req.provider }), + ); + + // Get the URI to eventually redirect the user to + const { uri, codeVerifier } = await client.code.getAuthorizationUri({ state }); + + // Record the details of the login attempt + await ctx.db.loginAttempts.create({ + data: { + id: flowId, + providerId: req.provider, + expiresAt: expiry.toISOString(), + codeVerifier, + state, + } + }); + + return { + authUrl: uri.toString(), + flowToken, + }; +} diff --git a/modules/auth_oauth2/utils/client.ts b/modules/auth_oauth2/utils/client.ts new file mode 100644 index 00000000..acde1bb9 --- /dev/null +++ b/modules/auth_oauth2/utils/client.ts @@ -0,0 +1,57 @@ +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) { + const providerCfg = cfg.providers[provider]; + if (!providerCfg) throw new RuntimeError("invalid_provider", { statusCode: 400 }); + + // TODO: Make this configurable + const baseUri = new URL("http://localhost:6420"); + + const redirectUri = new URL(`./modules/auth_oauth2/route/callback/${provider}`, baseUri.origin).toString(); + + return new OAuth2Client({ + clientId: providerCfg.clientId, + clientSecret: providerCfg.clientSecret, + authorizationEndpointUri: providerCfg.endpoints.authorization, + tokenUri: providerCfg.endpoints.token, + // TODO: Make this work with custom prefixes + redirectUri, + defaults: { + scope: providerCfg.endpoints.scopes, + }, + }); +} + +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 }); + + let json: unknown; + try { + json = await res.json(); + } catch { + throw new RuntimeError("bad_oauth_response", { statusCode: 502 }); + } + + if (typeof json !== "object" || json === null) { + throw new RuntimeError("bad_oauth_response", { statusCode: 502 }); + } + + const jsonObj = json as Record; + const uniqueIdent = jsonObj[provider.endpoints.userinfoKey]; + + if (typeof uniqueIdent !== "string" && typeof uniqueIdent !== "number") { + console.warn("Invalid userinfo response", jsonObj); + 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 new file mode 100644 index 00000000..a5490143 --- /dev/null +++ b/modules/auth_oauth2/utils/env.ts @@ -0,0 +1,55 @@ +import { Config, ProviderEndpoints } from "../config.ts"; +import { getFromOidcWellKnown } from "./wellknown.ts"; + +export interface FullConfig { + providers: Record; + oauthSecret: string; +} + +export interface ProviderConfig { + name: string; + 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 function getOauthSecret(): string | 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 providers = Object.fromEntries(providerArr.map(p => [p.name, p])); + + const oauthSecret = getOauthSecret(); + if (!oauthSecret) return null; + + return { providers, oauthSecret }; +} diff --git a/modules/auth_oauth2/utils/pages.ts b/modules/auth_oauth2/utils/pages.ts new file mode 100644 index 00000000..987530a1 --- /dev/null +++ b/modules/auth_oauth2/utils/pages.ts @@ -0,0 +1,14 @@ +export const OAUTH_DONE_HTML = ` + + + + + + Sign-In Complete + + +

Sign-In Complete

+

You can now close this page.

+ + +`; \ No newline at end of file diff --git a/modules/auth_oauth2/utils/state.ts b/modules/auth_oauth2/utils/state.ts new file mode 100644 index 00000000..779eb9f4 --- /dev/null +++ b/modules/auth_oauth2/utils/state.ts @@ -0,0 +1,161 @@ +import base64 from "https://deno.land/x/b64@1.1.28/src/base64.js"; + +type InputData = ArrayBufferLike | Uint8Array | string; + +/** + * Normalizes input data to a buffer. + * + * Although strings are often used as input data, webcrypto likes buffers a lot + * more. + * + * @param data An {@linkcode InputData} object to convert to a buffer + * @returns The buffer representation of the input data, encoding strings as UTF-8 + */ +function toBuffer(data: InputData): ArrayBufferLike { + if (typeof data === "string") { + return new TextEncoder().encode(data); + } else { + return data; + } +} + +/** + * Convert a secret into a WebCrypto-compatible key for AES-GCM encryption. + * + * Because `AES-GCM` requires a 256-bit key, this first hashes the secret with + * `SHA-256`. + * + * @param secret The {@linkcode InputData} to convert to a key + * @returns A key derived from the secret + */ +async function secretToKey(secret: InputData) { + const secretDigest = await crypto.subtle.digest( + "SHA-256", + toBuffer(secret), + ); + return await crypto.subtle.importKey( + "raw", + secretDigest, + "AES-GCM", + false, + ["encrypt", "decrypt"], + ); +} + +/** + * Generates a `state` parameter containing associated data. + * + * The data is encrypted with a random 12-byte nonce, and the nonce is prepended + * to the ciphertext. This means that the same data will not encrypt to the same + * ciphertext, preventing known-plaintext attacks. + * + * @param oauthSecret The secret to use to encrypt the state parameter + * @param data The data the state parameter should contain + * @returns A unique state parameter with data only the server can read + */ +export async function dataToState( + oauthSecret: InputData, + data: InputData, +): Promise { + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const encodedToken = toBuffer(data); + const oauthSecretKey = await secretToKey(oauthSecret); + + 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; +} + +/** + * Same as {@linkcode dataToState}, but returns the state as a base64 string + */ +export async function dataToStateStr( + oauthSecret: InputData, + data: InputData, +): Promise { + const state = await dataToState(oauthSecret, data); + return base64.fromArrayBuffer(state); +} + +/** + * Extracts the data from a `state` parameter. + * + * @param oauthSecret The secret to use to decrypt the state parameter + * @param state The `state` parameter to extract data from + * @returns The data contained in the state parameter + */ +export async function stateToData( + 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 data = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: nonce, + additionalData: new Uint8Array(0), + tagLength: 128, + }, + oauthSecretKey, + ciphertext, + ); + + return new Uint8Array(data); +} + + +/** + * Same as {@linkcode stateToData}, but returns the data as a string + */ +export async function stateToDataStr( + oauthSecret: string, + state: string, +): Promise { + const data = await stateToData(oauthSecret, state); + return new TextDecoder().decode(data); +} + +/** + * 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; + + if (bufLikeA.byteLength !== bufLikeB.byteLength) return false; + + 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; +} + + + + diff --git a/modules/auth_oauth2/utils/trace.ts b/modules/auth_oauth2/utils/trace.ts new file mode 100644 index 00000000..41dd4513 --- /dev/null +++ b/modules/auth_oauth2/utils/trace.ts @@ -0,0 +1,51 @@ +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/wellknown.ts b/modules/auth_oauth2/utils/wellknown.ts new file mode 100644 index 00000000..834fe3b0 --- /dev/null +++ b/modules/auth_oauth2/utils/wellknown.ts @@ -0,0 +1,40 @@ +import { RuntimeError } from "../module.gen.ts"; +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"); + + 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 { + 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"); + + + return { + authorization: authorization_endpoint, + token: token_endpoint, + userinfo: userinfo_endpoint, + scopes: scopes_supported.join(" "), + userinfoKey: "sub", + }; +} diff --git a/modules/auth_username_password/tests/e2e.ts b/modules/auth_username_password/tests/e2e.ts index 1c090b75..64ba9651 100644 --- a/modules/auth_username_password/tests/e2e.ts +++ b/modules/auth_username_password/tests/e2e.ts @@ -55,11 +55,7 @@ test("test_sign_in", async (ctx: TestContext) => { userToken: token.token, }); -<<<<<<< HEAD const { users: [user] } = await ctx.modules.users.fetchByUsername({ -======= - const { users: [user] } = await ctx.modules.users.fetchByUname({ ->>>>>>> 7d761a9 (eat: Create the `auth_username_pasword` module) usernames: [username], }); diff --git a/modules/users/scripts/fetch_by_uname.ts b/modules/users/scripts/fetch_by_uname.ts deleted file mode 100644 index 98acba09..00000000 --- a/modules/users/scripts/fetch_by_uname.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ScriptContext } from "../module.gen.ts"; -import { User } from "../utils/types.ts"; - -export interface Request { - usernames: string[]; -} - -export interface Response { - users: User[]; -} - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - const users = await ctx.db.user.findMany({ - where: { username: { in: req.usernames } }, - orderBy: { username: "desc" }, - }); - - return { users }; -}