From 801efc454e9dad3e793351ede0f621e7a10284e5 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 1/4] feat: Create an OAuth2 module for authenticating users --- modules/auth_oauth2/config.ts | 11 ++ .../migrations/20240508161825_/migration.sql | 45 +++++++ .../db/migrations/migration_lock.toml | 3 + modules/auth_oauth2/db/schema.prisma | 48 +++++++ modules/auth_oauth2/module.json | 58 ++++++++ modules/auth_oauth2/routes/login_callback.ts | 126 ++++++++++++++++++ modules/auth_oauth2/routes/login_link.ts | 91 +++++++++++++ modules/auth_oauth2/utils/client.ts | 54 ++++++++ modules/auth_oauth2/utils/env.ts | 55 ++++++++ modules/auth_oauth2/utils/state.ts | 46 +++++++ modules/auth_oauth2/utils/trace.ts | 51 +++++++ modules/auth_oauth2/utils/wellknown.ts | 40 ++++++ tests/basic/backend.json | 16 +++ 13 files changed, 644 insertions(+) create mode 100644 modules/auth_oauth2/config.ts create mode 100644 modules/auth_oauth2/db/migrations/20240508161825_/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/routes/login_link.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/state.ts create mode 100644 modules/auth_oauth2/utils/trace.ts create mode 100644 modules/auth_oauth2/utils/wellknown.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/20240508161825_/migration.sql b/modules/auth_oauth2/db/migrations/20240508161825_/migration.sql new file mode 100644 index 00000000..5986a899 --- /dev/null +++ b/modules/auth_oauth2/db/migrations/20240508161825_/migration.sql @@ -0,0 +1,45 @@ +-- CreateTable +CREATE TABLE "OAuthUsers" ( + "userId" UUID NOT NULL, + "provider" TEXT NOT NULL, + "sub" TEXT NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OAuthUsers_pkey" PRIMARY KEY ("provider","userId") +); + +-- CreateTable +CREATE TABLE "OAuthLoginAttempt" ( + "id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "state" TEXT NOT NULL, + "codeVerifier" TEXT NOT NULL, + "targetUrl" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "invalidatedAt" TIMESTAMP(3), + + CONSTRAINT "OAuthLoginAttempt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthCreds" ( + "id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "accessToken" TEXT NOT NULL, + "refreshToken" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "userToken" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "loginAttemptId" TEXT NOT NULL, + + CONSTRAINT "OAuthCreds_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthCreds_loginAttemptId_key" ON "OAuthCreds"("loginAttemptId"); + +-- AddForeignKey +ALTER TABLE "OAuthCreds" ADD CONSTRAINT "OAuthCreds_loginAttemptId_fkey" FOREIGN KEY ("loginAttemptId") REFERENCES "OAuthLoginAttempt"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 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..5f40c38f --- /dev/null +++ b/modules/auth_oauth2/db/schema.prisma @@ -0,0 +1,48 @@ +// Do not modify this `datasource` block +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model OAuthUsers { + userId String @db.Uuid + + provider String + sub String + createdAt DateTime @default(now()) @db.Timestamp + + @@id([provider, userId]) +} + +model OAuthLoginAttempt { + id String @id @default(uuid()) + + provider String + state String + codeVerifier String + targetUrl String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + completedAt DateTime? + invalidatedAt DateTime? + + creds OAuthCreds? +} + +model OAuthCreds { + id String @id @default(uuid()) + + provider String + accessToken String + refreshToken String + expiresAt DateTime + userToken String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + loginAttemptId String @unique + loginAttempt OAuthLoginAttempt @relation(fields: [loginAttemptId], references: [id]) +} + diff --git a/modules/auth_oauth2/module.json b/modules/auth_oauth2/module.json new file mode 100644 index 00000000..2c68c3b0 --- /dev/null +++ b/modules/auth_oauth2/module.json @@ -0,0 +1,58 @@ +{ + "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 diff --git a/modules/auth_oauth2/routes/login_callback.ts b/modules/auth_oauth2/routes/login_callback.ts new file mode 100644 index 00000000..bcc3c166 --- /dev/null +++ b/modules/auth_oauth2/routes/login_callback.ts @@ -0,0 +1,126 @@ +import { + RouteContext, + RuntimeError, + RouteRequest, + RouteResponse, +} 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"; + +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 }); + + // Ensure that the provider configurations are valid + const config = await getFullConfig(ctx.userConfig); + 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 login attempt stored in the database + const loginAttempt = await ctx.db.oAuthLoginAttempt.findUnique({ + where: { id: loginId, completedAt: null, invalidatedAt: null }, + }); + + 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 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(); + + // 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); + } 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: { + sub, + provider: loginAttempt.provider, + }, + }); + + 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}`); + + return new Response(response.body, { status: response.status, headers }); +} diff --git a/modules/auth_oauth2/routes/login_link.ts b/modules/auth_oauth2/routes/login_link.ts new file mode 100644 index 00000000..1b913ab0 --- /dev/null +++ b/modules/auth_oauth2/routes/login_link.ts @@ -0,0 +1,91 @@ +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/utils/client.ts b/modules/auth_oauth2/utils/client.ts new file mode 100644 index 00000000..2e0bedf0 --- /dev/null +++ b/modules/auth_oauth2/utils/client.ts @@ -0,0 +1,54 @@ +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) { + const providerCfg = cfg.providers[provider]; + if (!providerCfg) throw new RuntimeError("invalid_provider", { statusCode: 400 }); + + const redirectUri = new URL(`./modules/auth_oauth2/route/callback/${provider}`, uri.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/state.ts b/modules/auth_oauth2/utils/state.ts new file mode 100644 index 00000000..40a0f198 --- /dev/null +++ b/modules/auth_oauth2/utils/state.ts @@ -0,0 +1,46 @@ +import base64 from "https://deno.land/x/b64@1.1.28/src/base64.js"; + +const STATE_BYTES = 16; + + +type InputData = ArrayBufferLike | Uint8Array | string; + +/** + * 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)); +} + +/** + * Generates a new random string with `STATE_BYTES` bytes of entropy. + */ +export function generateStateStr(): string { + return base64.fromArrayBuffer(generateState()); +} + +/** + * 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/tests/basic/backend.json b/tests/basic/backend.json index 897a398e..6ce20409 100644 --- a/tests/basic/backend.json +++ b/tests/basic/backend.json @@ -45,6 +45,22 @@ "test": {} } } + }, + "auth_oauth2": { + "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" + } + } + } } } } From d2012c6a46316f4e1539eb664c525260560afb2e Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:58:02 -0400 Subject: [PATCH 2/4] feat(tokens): Add a `modify_meta` script. --- modules/tokens/module.json | 100 +++++++++++++------------- modules/tokens/scripts/extend.ts | 34 ++++----- modules/tokens/scripts/modify_meta.ts | 38 ++++++++++ modules/tokens/scripts/revoke.ts | 2 +- modules/tokens/tests/meta.ts | 42 +++++++++++ modules/tokens/tests/validate.ts | 4 +- modules/tokens/utils/types.ts | 5 ++ tests/basic/deno.lock | 2 +- 8 files changed, 158 insertions(+), 69 deletions(-) create mode 100644 modules/tokens/scripts/modify_meta.ts create mode 100644 modules/tokens/tests/meta.ts diff --git a/modules/tokens/module.json b/modules/tokens/module.json index 2cae135f..bbeca9e7 100644 --- a/modules/tokens/module.json +++ b/modules/tokens/module.json @@ -1,50 +1,54 @@ { - "name": "Tokens", - "description": "Create & verify tokens for authorization purposes.", - "icon": "lock", - "tags": [ - "core", - "utility" - ], - "authors": [ - "rivet-gg", - "NathanFlurry" - ], - "status": "stable", - "scripts": { - "create": { - "name": "Create Token" - }, - "fetch": { - "name": "Fetch Token", - "description": "Get a token by its ID." - }, - "fetch_by_token": { - "name": "Fetch by Token", - "description": "Get a token by its secret token." - }, - "revoke": { - "name": "Revoke Token", - "description": "Revoke a token, preventing it from being used again." - }, - "validate": { - "name": "Validate Token", - "description": "Validate a token. Throws an error if the token is invalid." - }, - "extend": { - "name": "Extend Token", - "description": "Extend or remove the expiration date of a token. (Only works on valid tokens.)" - } - }, - "errors": { - "token_not_found": { - "name": "Token Not Found" - }, - "token_revoked": { - "name": "Token Revoked" - }, - "token_expired": { - "name": "Token Expired" - } - } + "name": "Tokens", + "description": "Create & verify tokens for authorization purposes.", + "icon": "lock", + "tags": [ + "core", + "utility" + ], + "authors": [ + "rivet-gg", + "NathanFlurry" + ], + "status": "stable", + "scripts": { + "create": { + "name": "Create Token" + }, + "fetch": { + "name": "Fetch Token", + "description": "Get a token by its ID." + }, + "fetch_by_token": { + "name": "Fetch by Token", + "description": "Get a token by its secret token." + }, + "revoke": { + "name": "Revoke Token", + "description": "Revoke a token, preventing it from being used again." + }, + "validate": { + "name": "Validate Token", + "description": "Validate a token. Throws an error if the token is invalid." + }, + "extend": { + "name": "Extend Token", + "description": "Extend or remove the expiration date of a token. (Only works on valid tokens.)" + }, + "modify_meta": { + "name": "Modify Token Metadata", + "description": "Modify the token's associated metadata, additionally returning the old metadata. (Only works on valid tokens.)" + } + }, + "errors": { + "token_not_found": { + "name": "Token Not Found" + }, + "token_revoked": { + "name": "Token Revoked" + }, + "token_expired": { + "name": "Token Expired" + } + } } diff --git a/modules/tokens/scripts/extend.ts b/modules/tokens/scripts/extend.ts index 35fe2195..5247d473 100644 --- a/modules/tokens/scripts/extend.ts +++ b/modules/tokens/scripts/extend.ts @@ -1,9 +1,9 @@ import { ScriptContext } from "../module.gen.ts"; -import { TokenWithSecret, tokenFromRow } from "../utils/types.ts"; +import { tokenFromRow, TokenWithSecret } from "../utils/types.ts"; export interface Request { - token: string; - newExpiration: string | null; + token: string; + newExpiration: string | null; } export interface Response { @@ -14,22 +14,22 @@ export async function run( ctx: ScriptContext, req: Request, ): Promise { - // Ensure the token hasn't expired or been revoked yet - const { token } = await ctx.modules.tokens.validate({ - token: req.token, - }); + // Ensure the token hasn't expired or been revoked yet + const { token } = await ctx.modules.tokens.validate({ + token: req.token, + }); - // Update the token's expiration date - const newToken = await ctx.db.token.update({ - where: { - id: token.id, - }, - data: { - expireAt: req.newExpiration, - }, - }); + // Update the token's expiration date + const newToken = await ctx.db.token.update({ + where: { + id: token.id, + }, + data: { + expireAt: req.newExpiration, + }, + }); - // Return the updated token + // Return the updated token return { token: tokenFromRow(newToken), }; diff --git a/modules/tokens/scripts/modify_meta.ts b/modules/tokens/scripts/modify_meta.ts new file mode 100644 index 00000000..34d58f59 --- /dev/null +++ b/modules/tokens/scripts/modify_meta.ts @@ -0,0 +1,38 @@ +import { ScriptContext } from "../module.gen.ts"; +import { Token, tokenFromRow } from "../utils/types.ts"; + +export interface Request { + token: string; + newMeta: { [key: string]: any }; +} + +export interface Response { + token: Token; + oldMeta: { [key: string]: any }; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Ensure the token hasn't expired or been revoked yet + const { token } = await ctx.modules.tokens.validate({ + token: req.token, + }); + + // Update the token's expiration date + const newToken = await ctx.db.token.update({ + where: { + id: token.id, + }, + data: { + meta: req.newMeta, + }, + }); + + // Return the updated token + return { + token: tokenFromRow(newToken), + oldMeta: token.meta, + }; +} diff --git a/modules/tokens/scripts/revoke.ts b/modules/tokens/scripts/revoke.ts index ac0d8662..cb4d357f 100644 --- a/modules/tokens/scripts/revoke.ts +++ b/modules/tokens/scripts/revoke.ts @@ -1,4 +1,4 @@ -import { Prisma, ScriptContext } from "../module.gen.ts"; +import { ScriptContext } from "../module.gen.ts"; export interface Request { tokenIds: string[]; diff --git a/modules/tokens/tests/meta.ts b/modules/tokens/tests/meta.ts new file mode 100644 index 00000000..e8c0d2c9 --- /dev/null +++ b/modules/tokens/tests/meta.ts @@ -0,0 +1,42 @@ +import { test, TestContext } from "../module.gen.ts"; +import { + assertEquals, + assertExists, + assertRejects, +} from "https://deno.land/std@0.217.0/assert/mod.ts"; +import { RuntimeError } from "../module.gen.ts"; + +const METADATA_PRE = { state: "beforeModify" }; +const METADATA_POST = { state: "afterModify" }; + +test("modify_meta", async (ctx: TestContext) => { + const { token } = await ctx.modules.tokens.create({ + type: "test", + meta: METADATA_PRE, + }); + + const { tokens: [tokenPre] } = await ctx.modules.tokens.fetch({ + tokenIds: [token.id], + }); + assertExists(tokenPre); + // assertEquals(typeof tokenPre.meta, "string"); + assertEquals(tokenPre.meta, METADATA_PRE); + + const { oldMeta: fetchedOldMeta, token: tokenPost } = await ctx.modules.tokens + .modifyMeta({ + token: token.token, + newMeta: METADATA_POST, + }); + assertExists(tokenPost); + assertEquals(tokenPost.meta, METADATA_POST); + assertEquals(fetchedOldMeta, METADATA_PRE); + + await ctx.modules.tokens.revoke({ tokenIds: [token.id] }); + + const err = await assertRejects(() => + ctx.modules.tokens.modifyMeta({ + token: token.token, + newMeta: METADATA_POST, + }), RuntimeError); + assertEquals(err.code, "token_revoked"); +}); diff --git a/modules/tokens/tests/validate.ts b/modules/tokens/tests/validate.ts index 13a07a39..cbcecbe6 100644 --- a/modules/tokens/tests/validate.ts +++ b/modules/tokens/tests/validate.ts @@ -1,8 +1,8 @@ import { RuntimeError, test, TestContext } from "../module.gen.ts"; import { assertEquals, - assertRejects, assertGreater, + assertRejects, } from "https://deno.land/std@0.217.0/assert/mod.ts"; test( @@ -97,6 +97,6 @@ test( }, { ...token, expireAt: null, - }) + }); }, ); diff --git a/modules/tokens/utils/types.ts b/modules/tokens/utils/types.ts index 5e02f138..1281482b 100644 --- a/modules/tokens/utils/types.ts +++ b/modules/tokens/utils/types.ts @@ -18,6 +18,11 @@ export function tokenFromRow( ): TokenWithSecret { return { ...row, + // NOTE: Not sure why this is necessary— prisma seems to be stringifying + // all JSON values before returning them. + // + // Should look into more. + meta: row.meta ? JSON.parse(row.meta.toString()) : row.meta, createdAt: row.createdAt.toISOString(), expireAt: row.expireAt?.toISOString() ?? null, revokedAt: row.revokedAt?.toISOString() ?? null, diff --git a/tests/basic/deno.lock b/tests/basic/deno.lock index f493f1ee..2e0bc6e3 100644 --- a/tests/basic/deno.lock +++ b/tests/basic/deno.lock @@ -1455,7 +1455,7 @@ "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", - "https://esm.sh/ajv@8.12.0": "965ce16eff0cefef99e67478c5ee760928bd8931d40c3b958325cdd6ab6149f2", + "https://esm.sh/ajv@8.12.0": "cc1a73af661466c7f4e6a94d93ece78542d700f2165bdb16a531e9db8856c5aa", "https://esm.sh/v135/@aws-crypto/crc32@3.0.0/denonext/crc32.mjs": "f9a98501e686244b2f327c7791df0f1f7830b8769dd2815b9de7e2aaca1f657f", "https://esm.sh/v135/@aws-crypto/crc32c@3.0.0/denonext/crc32c.mjs": "5acc8d5648bf7266477caacf836109147e2815cb13ba198270b783e77380cd19", "https://esm.sh/v135/@aws-crypto/ie11-detection@3.0.0/denonext/ie11-detection.mjs": "ae42f42b38941df739396432e9da809820c13a9f780bccb635326324bf51464e", From 7d177f86c8cfdb92d57d57bda5707d54ecb0bce5 Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Sat, 29 Jun 2024 19:19:09 -0400 Subject: [PATCH 3/4] feat: Create the `auth_provider` module --- modules/auth_providers/config.ts | 11 +++++ .../20240629231731_init/migration.sql | 15 ++++++ .../db/migrations/migration_lock.toml | 3 ++ modules/auth_providers/db/schema.prisma | 16 +++++++ modules/auth_providers/module.json | 48 +++++++++++++++++++ .../scripts/add_provider_to_user.ts | 43 +++++++++++++++++ .../scripts/get_provider_data.ts | 44 +++++++++++++++++ .../auth_providers/scripts/list_providers.ts | 31 ++++++++++++ .../scripts/set_provider_data.ts | 45 +++++++++++++++++ modules/auth_providers/utils/types.ts | 8 ++++ tests/basic/backend.json | 3 ++ 11 files changed, 267 insertions(+) create mode 100644 modules/auth_providers/config.ts create mode 100644 modules/auth_providers/db/migrations/20240629231731_init/migration.sql create mode 100644 modules/auth_providers/db/migrations/migration_lock.toml create mode 100644 modules/auth_providers/db/schema.prisma create mode 100644 modules/auth_providers/module.json create mode 100644 modules/auth_providers/scripts/add_provider_to_user.ts create mode 100644 modules/auth_providers/scripts/get_provider_data.ts create mode 100644 modules/auth_providers/scripts/list_providers.ts create mode 100644 modules/auth_providers/scripts/set_provider_data.ts create mode 100644 modules/auth_providers/utils/types.ts diff --git a/modules/auth_providers/config.ts b/modules/auth_providers/config.ts new file mode 100644 index 00000000..76e61b58 --- /dev/null +++ b/modules/auth_providers/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_providers/db/migrations/20240629231731_init/migration.sql b/modules/auth_providers/db/migrations/20240629231731_init/migration.sql new file mode 100644 index 00000000..1cc0d988 --- /dev/null +++ b/modules/auth_providers/db/migrations/20240629231731_init/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "ProviderEntries" ( + "userId" UUID NOT NULL, + "providerType" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "providerData" JSONB NOT NULL, + + CONSTRAINT "ProviderEntries_pkey" PRIMARY KEY ("userId","providerType","providerId") +); + +-- CreateIndex +CREATE INDEX "ProviderEntries_userId_idx" ON "ProviderEntries"("userId"); + +-- CreateIndex +CREATE INDEX "ProviderEntries_providerType_providerId_idx" ON "ProviderEntries"("providerType", "providerId"); diff --git a/modules/auth_providers/db/migrations/migration_lock.toml b/modules/auth_providers/db/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/modules/auth_providers/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_providers/db/schema.prisma b/modules/auth_providers/db/schema.prisma new file mode 100644 index 00000000..0fb3c759 --- /dev/null +++ b/modules/auth_providers/db/schema.prisma @@ -0,0 +1,16 @@ +// Do not modify this `datasource` block +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model ProviderEntries { + userId String @db.Uuid + providerType String + providerId String + providerData Json + + @@id([userId, providerType, providerId]) + @@index([userId]) + @@index([providerType, providerId]) +} diff --git a/modules/auth_providers/module.json b/modules/auth_providers/module.json new file mode 100644 index 00000000..7d9fb6aa --- /dev/null +++ b/modules/auth_providers/module.json @@ -0,0 +1,48 @@ +{ + "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": {} + }, + "scripts": { + "list_providers": { + "name": "List Providers", + "description": "List all providers the user is identified with.", + "public": true + }, + "get_provider_data": { + "name": "Get Provider Data", + "description": "Get the data associated with a specific provider for a user." + }, + "set_provider_data": { + "name": "Set Provider Data", + "description": "Set the data associated with a specific provider for a user." + }, + "add_provider_to_user": { + "name": "Add Provider To User", + "description": "Add a new provider and its associated data to a user." + } + }, + "routes": {}, + "errors": { + "provider_not_found": { + "name": "Provider Not Found" + }, + "provider_already_added": { + "name": "Provider Already Added" + } + } +} \ No newline at end of file diff --git a/modules/auth_providers/scripts/add_provider_to_user.ts b/modules/auth_providers/scripts/add_provider_to_user.ts new file mode 100644 index 00000000..feb4e79d --- /dev/null +++ b/modules/auth_providers/scripts/add_provider_to_user.ts @@ -0,0 +1,43 @@ +import { RuntimeError, ScriptContext, prisma } from "../module.gen.ts"; +import { ProviderData, ProviderInfo } from "../utils/types.ts"; + +export interface Request { + userToken: string; + info: ProviderInfo; + data: ProviderData & prisma.Prisma.InputJsonValue; +} + +export interface Response { + providers: ProviderInfo[]; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttle({ + key: req.userToken, + period: 10, + requests: 10, + type: "user", + }); + + // Ensure the user token is valid and get the user ID + const { userId } = await ctx.modules.users.authenticateToken({ userToken: req.userToken } ); + + // Error if this provider is ALREADY associated with the user + const { data: prevData } = await ctx.modules.authProviders.getProviderData({ userToken: req.userToken, info: req.info }); + if (prevData) throw new RuntimeError("provider_already_added"); + + // Add a new entry to the table with the associated data + await ctx.db.providerEntries.create({ + data: { + userId, + providerType: req.info.providerType, + providerId: req.info.providerId, + providerData: req.data, + }, + }); + + return await ctx.modules.authProviders.listProviders({ userToken: req.userToken }); +} diff --git a/modules/auth_providers/scripts/get_provider_data.ts b/modules/auth_providers/scripts/get_provider_data.ts new file mode 100644 index 00000000..6f5c31a8 --- /dev/null +++ b/modules/auth_providers/scripts/get_provider_data.ts @@ -0,0 +1,44 @@ +import { ScriptContext } from "../module.gen.ts"; +import { ProviderData, ProviderInfo } from "../utils/types.ts"; + +export interface Request { + userToken: string; + info: ProviderInfo; +} + +export interface Response { + data: ProviderData | null; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttle({ + key: req.userToken, + period: 10, + requests: 10, + type: "user", + }); + + const { userId } = await ctx.modules.users.authenticateToken({ userToken: req.userToken } ); + + const provider = await ctx.db.providerEntries.findFirst({ + where: { + userId, + providerType: req.info.providerType, + providerId: req.info.providerId, + }, + select: { + providerData: true, + } + }); + + const data = provider?.providerData; + + if (data && typeof data === 'object' && !Array.isArray(data)) { + return { data }; + } else { + return { data: null }; + } +} diff --git a/modules/auth_providers/scripts/list_providers.ts b/modules/auth_providers/scripts/list_providers.ts new file mode 100644 index 00000000..969c6b61 --- /dev/null +++ b/modules/auth_providers/scripts/list_providers.ts @@ -0,0 +1,31 @@ +import { ScriptContext } from "../module.gen.ts"; +import { ProviderInfo } from "../utils/types.ts"; + +export interface Request { + userToken: string; +} + +export interface Response { + providers: ProviderInfo[]; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + + const { userId } = await ctx.modules.users.authenticateToken({ userToken: req.userToken } ); + + return { + providers: await ctx.db.providerEntries.findMany({ + where: { + userId, + }, + select: { + providerType: true, + providerId: true, + } + }), + }; +} diff --git a/modules/auth_providers/scripts/set_provider_data.ts b/modules/auth_providers/scripts/set_provider_data.ts new file mode 100644 index 00000000..66042c10 --- /dev/null +++ b/modules/auth_providers/scripts/set_provider_data.ts @@ -0,0 +1,45 @@ +import { ScriptContext, Empty, RuntimeError, prisma } from "../module.gen.ts"; +import { ProviderData, ProviderInfo } from "../utils/types.ts"; + +export interface Request { + userToken: string; + info: ProviderInfo; + data: ProviderData & prisma.Prisma.InputJsonValue; +} + +export type Response = Empty; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttle({ + key: req.userToken, + period: 10, + requests: 10, + type: "user", + }); + + // Ensure the user token is valid and get the user ID + const { userId } = await ctx.modules.users.authenticateToken({ userToken: req.userToken } ); + + // Error if this provider is not associated with the user + const { data: prevData } = await ctx.modules.authProviders.getProviderData({ userToken: req.userToken, info: req.info }); + if (!prevData) throw new RuntimeError("provider_not_found"); + + // Update the provider data where userId, providerType, and providerId match + await ctx.db.providerEntries.update({ + where: { + userId_providerType_providerId: { + userId, + providerType: req.info.providerType, + providerId: req.info.providerId, + } + }, + data: { + providerData: req.data, + }, + }); + + return {}; +} diff --git a/modules/auth_providers/utils/types.ts b/modules/auth_providers/utils/types.ts new file mode 100644 index 00000000..10a4aabd --- /dev/null +++ b/modules/auth_providers/utils/types.ts @@ -0,0 +1,8 @@ +export type ProviderData = Record; + +export interface ProviderInfo { + providerType: string; + providerId: string; +} + + diff --git a/tests/basic/backend.json b/tests/basic/backend.json index 6ce20409..935b13a3 100644 --- a/tests/basic/backend.json +++ b/tests/basic/backend.json @@ -38,6 +38,9 @@ } } }, + "auth_providers": { + "registry": "local" + }, "email": { "registry": "local", "config": { From 4d76ab6536ed0acd2d223388f84927df2bd1c390 Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Sun, 30 Jun 2024 21:28:08 -0400 Subject: [PATCH 4/4] feat: Give the `auth_oauth2` module its own flow management --- .../migrations/20240508161825_/migration.sql | 45 ------ .../20240701012627_init/migration.sql | 14 ++ modules/auth_oauth2/db/schema.prisma | 40 +---- modules/auth_oauth2/module.json | 20 ++- modules/auth_oauth2/routes/login_callback.ts | 133 +++++++--------- modules/auth_oauth2/routes/login_link.ts | 91 ----------- modules/auth_oauth2/scripts/get_status.ts | 41 +++++ modules/auth_oauth2/scripts/start_login.ts | 56 +++++++ modules/auth_oauth2/utils/client.ts | 7 +- modules/auth_oauth2/utils/flow.ts | 148 ++++++++++++++++++ modules/auth_oauth2/utils/pages.ts | 14 ++ modules/auth_oauth2/utils/state.ts | 133 ++++++++++++++-- tests/basic/deno.lock | 13 ++ 13 files changed, 490 insertions(+), 265 deletions(-) delete mode 100644 modules/auth_oauth2/db/migrations/20240508161825_/migration.sql create mode 100644 modules/auth_oauth2/db/migrations/20240701012627_init/migration.sql delete mode 100644 modules/auth_oauth2/routes/login_link.ts create mode 100644 modules/auth_oauth2/scripts/get_status.ts create mode 100644 modules/auth_oauth2/scripts/start_login.ts create mode 100644 modules/auth_oauth2/utils/flow.ts create mode 100644 modules/auth_oauth2/utils/pages.ts diff --git a/modules/auth_oauth2/db/migrations/20240508161825_/migration.sql b/modules/auth_oauth2/db/migrations/20240508161825_/migration.sql deleted file mode 100644 index 5986a899..00000000 --- a/modules/auth_oauth2/db/migrations/20240508161825_/migration.sql +++ /dev/null @@ -1,45 +0,0 @@ --- CreateTable -CREATE TABLE "OAuthUsers" ( - "userId" UUID NOT NULL, - "provider" TEXT NOT NULL, - "sub" TEXT NOT NULL, - "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "OAuthUsers_pkey" PRIMARY KEY ("provider","userId") -); - --- CreateTable -CREATE TABLE "OAuthLoginAttempt" ( - "id" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "state" TEXT NOT NULL, - "codeVerifier" TEXT NOT NULL, - "targetUrl" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "completedAt" TIMESTAMP(3), - "invalidatedAt" TIMESTAMP(3), - - CONSTRAINT "OAuthLoginAttempt_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "OAuthCreds" ( - "id" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "accessToken" TEXT NOT NULL, - "refreshToken" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "userToken" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "loginAttemptId" TEXT NOT NULL, - - CONSTRAINT "OAuthCreds_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "OAuthCreds_loginAttemptId_key" ON "OAuthCreds"("loginAttemptId"); - --- AddForeignKey -ALTER TABLE "OAuthCreds" ADD CONSTRAINT "OAuthCreds_loginAttemptId_fkey" FOREIGN KEY ("loginAttemptId") REFERENCES "OAuthLoginAttempt"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/modules/auth_oauth2/db/migrations/20240701012627_init/migration.sql b/modules/auth_oauth2/db/migrations/20240701012627_init/migration.sql new file mode 100644 index 00000000..4030d48b --- /dev/null +++ b/modules/auth_oauth2/db/migrations/20240701012627_init/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "LoginAttempts" ( + "id" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "state" TEXT NOT NULL, + "codeVerifier" TEXT NOT NULL, + "identifier" TEXT, + "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/schema.prisma b/modules/auth_oauth2/db/schema.prisma index 5f40c38f..eb22ff26 100644 --- a/modules/auth_oauth2/db/schema.prisma +++ b/modules/auth_oauth2/db/schema.prisma @@ -4,45 +4,17 @@ datasource db { url = env("DATABASE_URL") } -model OAuthUsers { - userId String @db.Uuid - - provider String - sub String - createdAt DateTime @default(now()) @db.Timestamp - - @@id([provider, userId]) -} - -model OAuthLoginAttempt { +model LoginAttempts { id String @id @default(uuid()) - provider String + providerId String state String codeVerifier String - targetUrl String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - completedAt DateTime? - invalidatedAt DateTime? - - creds OAuthCreds? -} + identifier String? -model OAuthCreds { - id String @id @default(uuid()) - - provider String - accessToken String - refreshToken String + startedAt DateTime @default(now()) expiresAt DateTime - userToken String - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - loginAttemptId String @unique - loginAttempt OAuthLoginAttempt @relation(fields: [loginAttemptId], references: [id]) + completedAt DateTime? + invalidatedAt DateTime? } - diff --git a/modules/auth_oauth2/module.json b/modules/auth_oauth2/module.json index 2c68c3b0..174b6586 100644 --- a/modules/auth_oauth2/module.json +++ b/modules/auth_oauth2/module.json @@ -14,16 +14,11 @@ "status": "beta", "dependencies": { "rate_limit": {}, + "auth_providers": {}, "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.", @@ -31,7 +26,18 @@ "pathPrefix": "/callback/" } }, - "scripts": {}, + "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_status": { + "name": "Get Status", + "description": "Check the status of a OAuth login using the flow token. Returns the status of the login flow.", + "public": true + } + }, "errors": { "already_friends": { "name": "Already Friends" diff --git a/modules/auth_oauth2/routes/login_callback.ts b/modules/auth_oauth2/routes/login_callback.ts index bcc3c166..8f38424e 100644 --- a/modules/auth_oauth2/routes/login_callback.ts +++ b/modules/auth_oauth2/routes/login_callback.ts @@ -5,122 +5,101 @@ import { RouteResponse, } 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 { compareConstantTime, stateToDataStr } from "../utils/state.ts"; +import { OAUTH_DONE_HTML } from "../utils/pages.ts"; + export async function handle( ctx: RouteContext, req: RouteRequest, ): Promise { - // Max 2 login attempts per IP per minute + // 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.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); + // Get the URI that this request was made to + const uri = new URL(req.url); - if (!loginId || !codeVerifier || !state) throw new RuntimeError("missing_login_data", { statusCode: 400 }); + // 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.oAuthLoginAttempt.findUnique({ - where: { id: loginId, completedAt: null, invalidatedAt: null }, + const loginAttempt = await ctx.db.loginAttempts.findUnique({ + where: { + id: flowId, + }, }); - 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 }); + + // 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[loginAttempt.provider]; + const provider = config.providers[providerId]; if (!provider) throw new RuntimeError("invalid_provider", { statusCode: 400 }); // Get the oauth client - const client = getClient(config, provider.name, new URL(req.url)); + const client = getClient(config, provider.name); 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(); - // Get the user's tokens and sub let tokens: Tokens; - let sub: string; + let ident: 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 }); + ident = await getUserUniqueIdentifier(tokens.accessToken, 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({ + // Update the login attempt + await ctx.db.loginAttempts.update({ where: { - sub, - provider: loginAttempt.provider, + id: flowId, }, - }); - - 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, + identifier: ident, + completedAt: new Date(), }, }); - - 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}`); - - return new Response(response.body, { status: response.status, headers }); + return new RouteResponse( + OAUTH_DONE_HTML, + { + status: 200, + headers: { + "Content-Type": "text/html", + }, + }, + ); } 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_status.ts b/modules/auth_oauth2/scripts/get_status.ts new file mode 100644 index 00000000..b80d18ee --- /dev/null +++ b/modules/auth_oauth2/scripts/get_status.ts @@ -0,0 +1,41 @@ +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 { + 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()) 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) { + 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..4e4aee0a --- /dev/null +++ b/modules/auth_oauth2/scripts/start_login.ts @@ -0,0 +1,56 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { getClient } from "../utils/client.ts"; +import { getFullConfig } from "../utils/env.ts"; +import { createFlowToken } from "../utils/flow.ts"; +import { dataToStateStr } from "../utils/state.ts"; + +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 { token, flowId, expiry } = await createFlowToken(ctx); + + // 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: token.token, + }; +} diff --git a/modules/auth_oauth2/utils/client.ts b/modules/auth_oauth2/utils/client.ts index 2e0bedf0..acde1bb9 100644 --- a/modules/auth_oauth2/utils/client.ts +++ b/modules/auth_oauth2/utils/client.ts @@ -2,11 +2,14 @@ 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) { const providerCfg = cfg.providers[provider]; if (!providerCfg) throw new RuntimeError("invalid_provider", { statusCode: 400 }); - const redirectUri = new URL(`./modules/auth_oauth2/route/callback/${provider}`, uri.origin).toString(); + // 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, diff --git a/modules/auth_oauth2/utils/flow.ts b/modules/auth_oauth2/utils/flow.ts new file mode 100644 index 00000000..a66b1b8f --- /dev/null +++ b/modules/auth_oauth2/utils/flow.ts @@ -0,0 +1,148 @@ +import { ProviderEndpoints } from "../config.ts"; +import { RuntimeError, ScriptContext } from "../module.gen.ts"; + +/** + * The token type that designates that this is a flow token + */ +const FLOW_TYPE = "auth_oauth_flow"; + +/** + * Number of seconds after flow start that the flow will cease to be valid. + * + * This is currently hardcoded to 30 minutes, but it may be configurable in the + * future. + */ +const FLOW_EXPIRE_TIME = 30 * 60; + +/** + * Calculates when the flow should expire using the current server time. + * + * Leap seconds are not accounted for because they really don't matter. + * + * @returns The `Date` object for when the flow should expire. + */ +function getExpiryTime() { + const expiryTimeMs = Date.now() + FLOW_EXPIRE_TIME * 1000; + return new Date(expiryTimeMs); +} + +/** + * @param ctx The ScriptContext with which to call tokens.create + * @returns A flow token (TokenWithSecret) with the correct meta and expiry + * time, the flow ID, and the expiry time. + */ +export async function createFlowToken(ctx: ScriptContext) { + const flowId = crypto.randomUUID(); + const expiry = getExpiryTime(); + const { token } = await ctx.modules.tokens.create({ + type: FLOW_TYPE, + meta: { flowId }, + expireAt: expiry.toISOString(), + }); + return { token, flowId, expiry }; +} + +export type FlowStatus = + | { + status: "complete"; + userToken: string; + } + | { status: "pending" } + | { status: "expired" } + | { status: "cancelled" }; + +export async function getFlowStatus( + ctx: ScriptContext, + flowToken: string, +): Promise { + const { tokens: [flowData] } = await ctx.modules.tokens.fetchByToken({ + tokens: [flowToken], + }); + + if (!flowData || flowData.type !== FLOW_TYPE) { + throw new RuntimeError("flow_not_found"); + } + + // NOTE: Any tokens without an expiry date will always be expired + const expireDate = flowData.expireAt + ? new Date(flowData.expireAt) + : new Date(0); + + if (flowData.revokedAt) { + return { status: "cancelled" }; + } else if (expireDate.getTime() <= Date.now()) { + return { status: "expired" }; + } 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( + ctx: ScriptContext, + flowToken: string, +): Promise { + const status = await getFlowStatus(ctx, flowToken); + const { tokens: [{ id: flowId }] } = await ctx.modules.tokens.fetchByToken({ + tokens: [flowToken], + }); + + switch (status.status) { + case "complete": + throw new RuntimeError("already_completed"); + case "expired": + throw new RuntimeError("flow_expired"); + case "cancelled": + throw new RuntimeError("flow_cancelled"); + + case "pending": + await ctx.modules.tokens.revoke({ tokenIds: [flowId] }); + return; + } +} + +export async function completeFlow( + ctx: ScriptContext, + flowToken: string, + userId: string, + additionalData: unknown, +): Promise { + const status = await getFlowStatus(ctx, flowToken); + switch (status.status) { + case "complete": + throw new RuntimeError("already_completed"); + case "expired": + throw new RuntimeError("flow_expired"); + case "cancelled": + throw new RuntimeError("flow_cancelled"); + + case "pending": + break; + } + const { token } = await ctx.modules.users.createToken({ userId }); + await ctx.modules.tokens.modifyMeta({ + 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_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 index 40a0f198..779eb9f4 100644 --- a/modules/auth_oauth2/utils/state.ts +++ b/modules/auth_oauth2/utils/state.ts @@ -1,24 +1,135 @@ import base64 from "https://deno.land/x/b64@1.1.28/src/base64.js"; -const STATE_BYTES = 16; +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; + } +} -type InputData = ArrayBufferLike | Uint8Array | string; +/** + * 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 new random `STATE_BYTES`-byte state buffer. + * 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. * - * @returns A new random state buffer + * @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 function generateState(): ArrayBufferLike { - return crypto.getRandomValues(new Uint8Array(STATE_BYTES)); +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; } /** - * Generates a new random string with `STATE_BYTES` bytes of entropy. + * Same as {@linkcode dataToState}, but returns the state as a base64 string */ -export function generateStateStr(): string { - return base64.fromArrayBuffer(generateState()); +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); } /** @@ -44,3 +155,7 @@ export function compareConstantTime(a: InputData, b: InputData): boolean { } return result === 0; } + + + + 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",