Skip to content
This repository was archived by the owner on Sep 17, 2024. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions modules/auth_oauth2/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface Config {
providers: Record<string, ProviderEndpoints | string>;
}

export interface ProviderEndpoints {
authorization: string;
token: string;
userinfo: string;
scopes: string;
userinfoKey: string;
}
Original file line number Diff line number Diff line change
@@ -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")
);
3 changes: 3 additions & 0 deletions modules/auth_oauth2/db/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -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"
20 changes: 20 additions & 0 deletions modules/auth_oauth2/db/schema.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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?

startedAt DateTime @default(now())
expiresAt DateTime
completedAt DateTime?
invalidatedAt DateTime?
}
64 changes: 64 additions & 0 deletions modules/auth_oauth2/module.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"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": {},
"auth_providers": {},
"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_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"
},
"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"
}
}
}
105 changes: 105 additions & 0 deletions modules/auth_oauth2/routes/login_callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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<RouteResponse> {
// 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,
completedAt: new Date(),
},
});

return new RouteResponse(
OAUTH_DONE_HTML,
{
status: 200,
headers: {
"Content-Type": "text/html",
},
},
);
}
41 changes: 41 additions & 0 deletions modules/auth_oauth2/scripts/get_status.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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" };
}
}
56 changes: 56 additions & 0 deletions modules/auth_oauth2/scripts/start_login.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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,
};
}
57 changes: 57 additions & 0 deletions modules/auth_oauth2/utils/client.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, unknown>;
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();
}
Loading