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 May 10, 2024
1 parent 1317b2b commit d4869d0
Show file tree
Hide file tree
Showing 13 changed files with 573 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;
}
45 changes: 45 additions & 0 deletions modules/auth_oauth2/db/migrations/20240508161825_/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
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"
48 changes: 48 additions & 0 deletions modules/auth_oauth2/db/schema.prisma
Original file line number Diff line number Diff line change
@@ -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])
}

33 changes: 33 additions & 0 deletions modules/auth_oauth2/module.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: OAuth Authentication
description: Authenticate users with OAuth 2.0.
icon: key
tags:
- core
- auth
- user
authors:
- rivet-gg
- Skyler Calaman
status: stable
dependencies:
users: {}
tokens: {}
rate_limit: {}
scripts:
login_link:
name: Login Link
description: Generate a login link for accessing OpenGB.
public: true
api:
methods: [GET]
data: [query]
login_callback:
name: OAuth Redirect Callback
description: Verify a user's OAuth login and create a session.
public: true
api:
methods: [GET]
data: [query]
errors:
invalid_config:
name: Invalid OAuth Provider Configuration
131 changes: 131 additions & 0 deletions modules/auth_oauth2/scripts/login_callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { ScriptContext, RuntimeError, Empty } from "../_gen/scripts/login_callback.ts";
import { getHttpPath, 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";

export type Request = Record<string, unknown>;
export type Response = Empty;

export async function run(
ctx: ScriptContext,
req: Request,
): Promise<Response> {
// 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);
if (!client.config.redirectUri) throw new RuntimeError("invalid_config", { statusCode: 500 });


// Get the URI that this request was made to
const uri = new URL(client.config.redirectUri);

const path = getHttpPath(ctx);
if (!path) throw new RuntimeError("invalid_request", { statusCode: 400 });
uri.pathname = path;

for (const key in req) {
const value = req[key];
if (typeof value === "string") {
uri.searchParams.set(key, value);
} else {
uri.searchParams.set(key, JSON.stringify(value));
}
}
const uriStr = uri.toString();

// Get the user's tokens and sub
let tokens: Awaited<ReturnType<typeof client.code.getToken>>;
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,
},
});


// Redirect the user to the target URL
ctx.statusCode = 303;
ctx.headers.set("Location", loginAttempt.targetUrl);

// Set token cookie and expire state cookie
const cookieAttribs = `Path=/; Max-Age=${expiresIn}; SameSite=Lax; Expires=${expiry.toUTCString()}`;
ctx.headers.append("Set-Cookie", `token=${token.token}; ${cookieAttribs}`);

const expireAttribs = `Path=/; Max-Age=0; SameSite=Lax; Expires=${new Date(0).toUTCString()}`;
ctx.headers.append("Set-Cookie", `login_id=EXPIRED; ${expireAttribs}`);
ctx.headers.append("Set-Cookie", `code_verifier=EXPIRED; ${expireAttribs}`);
ctx.headers.append("Set-Cookie", `state=EXPIRED; ${expireAttribs}`);

return {};
}
46 changes: 46 additions & 0 deletions modules/auth_oauth2/scripts/login_link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ScriptContext, RuntimeError, Empty } from "../_gen/scripts/login_link.ts";
import { getFullConfig } from "../utils/env.ts";
import { getClient } from "../utils/client.ts";
import { generateStateStr } from "../utils/state.ts";

export interface Request {
provider: string;
targetUrl: string;
}

export type Response = Empty;

export async function run(
ctx: ScriptContext,
req: Request,
): Promise<Response> {
// Max 5 login attempts per IP per minute
ctx.modules.rateLimit.throttlePublic({ requests: 5, period: 60 });

// Ensure that the provider configurations are valid
const providers = await getFullConfig(ctx.userConfig);
if (!providers) throw new RuntimeError("invalid_config", { statusCode: 500 });

const client = getClient(providers, req.provider);
const state = generateStateStr();

const { uri, codeVerifier } = await client.code.getAuthorizationUri({ state });

const { id: loginId } = await ctx.db.oAuthLoginAttempt.create({
data: {
provider: req.provider,
targetUrl: req.targetUrl,
state,
codeVerifier,
},
});

ctx.statusCode = 303;
ctx.headers.set("Location", uri.toString());

ctx.headers.set("Cache-Control", "no-store");
ctx.headers.append("Set-Cookie", `login_id=${encodeURIComponent(loginId)}; SameSite=Lax; Path=/; Max-Age=300`);
ctx.headers.append("Set-Cookie", `code_verifier=${encodeURIComponent(codeVerifier)}; SameSite=Lax; Path=/; Max-Age=300`);
ctx.headers.append("Set-Cookie", `state=${encodeURIComponent(state)}; SameSite=Lax; Path=/; Max-Age=300`);
return {};
}
52 changes: 52 additions & 0 deletions modules/auth_oauth2/utils/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { OAuth2Client } from "https://deno.land/x/[email protected]/mod.ts";
import { FullConfig, ProviderConfig } from "./env.ts";
import { RuntimeError } from "../_gen/mod.ts";

export function getClient(cfg: FullConfig, provider: string) {
const providerCfg = cfg.providers[provider];
if (!providerCfg) throw new RuntimeError("invalid_provider", { statusCode: 400 });

return new OAuth2Client({
clientId: providerCfg.clientId,
clientSecret: providerCfg.clientSecret,
authorizationEndpointUri: providerCfg.endpoints.authorization,
tokenUri: providerCfg.endpoints.token,
// TODO: Use a real redirect URI
redirectUri: "http://localhost:8080/modules/auth_oauth2/scripts/login_callback/call",
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

0 comments on commit d4869d0

Please sign in to comment.