Skip to content
This repository has been archived by the owner on Sep 17, 2024. It is now read-only.

Commit

Permalink
feat: Create an OAuth2 module for authenticating users
Browse files Browse the repository at this point in the history
  • Loading branch information
Blckbrry-Pi committed Jul 5, 2024
1 parent a603a06 commit 78ce41b
Show file tree
Hide file tree
Showing 19 changed files with 1,005 additions and 0 deletions.
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,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")
);
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"
21 changes: 21 additions & 0 deletions modules/auth_oauth2/db/schema.prisma
Original file line number Diff line number Diff line change
@@ -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?
}
74 changes: 74 additions & 0 deletions modules/auth_oauth2/module.json
Original file line number Diff line number Diff line change
@@ -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": {},
"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
},
"add_to_user": {
"name": "Add OAuth Login to User",
"description": "Use a finished OAuth flow to add the OAuth login to an already-authenticated users.",
"public": true
},
"login_to_user": {
"name": "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"
}
}
}
106 changes: 106 additions & 0 deletions modules/auth_oauth2/routes/login_callback.ts
Original file line number Diff line number Diff line change
@@ -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/[email protected]/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,
tokenData: { ...tokens },
completedAt: new Date(),
},
});

return new RouteResponse(
OAUTH_DONE_HTML,
{
status: 200,
headers: {
"Content-Type": "text/html",
},
},
);
}
64 changes: 64 additions & 0 deletions modules/auth_oauth2/scripts/add_to_user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { RuntimeError, ScriptContext } from "../module.gen.ts";

export interface Request {
flowToken: string;
userToken: string;
}

export type Response = ReturnType<ScriptContext["modules"]["authProviders"]["addProviderToUser"]>;

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()) {
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.authProviders.addProviderToUser({
userToken: req.userToken,
info: {
providerType: "oauth2",
providerId: flow.providerId,
},
uniqueData: {
identifier: flow.identifier,
},
additionalData: tokenData,
});
}
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 && 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" };
}
}
Loading

0 comments on commit 78ce41b

Please sign in to comment.