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

Commit fd486fb

Browse files
Blckbrry-PiNathanFlurry
authored andcommitted
feat: Create an OAuth2 module for authenticating users
1 parent 858ce6d commit fd486fb

File tree

13 files changed

+615
-0
lines changed

13 files changed

+615
-0
lines changed

modules/auth_oauth2/config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export interface Config {
2+
providers: Record<string, ProviderEndpoints | string>;
3+
}
4+
5+
export interface ProviderEndpoints {
6+
authorization: string;
7+
token: string;
8+
userinfo: string;
9+
scopes: string;
10+
userinfoKey: string;
11+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
-- CreateTable
2+
CREATE TABLE "OAuthUsers" (
3+
"userId" UUID NOT NULL,
4+
"provider" TEXT NOT NULL,
5+
"sub" TEXT NOT NULL,
6+
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
7+
8+
CONSTRAINT "OAuthUsers_pkey" PRIMARY KEY ("provider","userId")
9+
);
10+
11+
-- CreateTable
12+
CREATE TABLE "OAuthLoginAttempt" (
13+
"id" TEXT NOT NULL,
14+
"provider" TEXT NOT NULL,
15+
"state" TEXT NOT NULL,
16+
"codeVerifier" TEXT NOT NULL,
17+
"targetUrl" TEXT NOT NULL,
18+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
19+
"updatedAt" TIMESTAMP(3) NOT NULL,
20+
"completedAt" TIMESTAMP(3),
21+
"invalidatedAt" TIMESTAMP(3),
22+
23+
CONSTRAINT "OAuthLoginAttempt_pkey" PRIMARY KEY ("id")
24+
);
25+
26+
-- CreateTable
27+
CREATE TABLE "OAuthCreds" (
28+
"id" TEXT NOT NULL,
29+
"provider" TEXT NOT NULL,
30+
"accessToken" TEXT NOT NULL,
31+
"refreshToken" TEXT NOT NULL,
32+
"expiresAt" TIMESTAMP(3) NOT NULL,
33+
"userToken" TEXT NOT NULL,
34+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
35+
"updatedAt" TIMESTAMP(3) NOT NULL,
36+
"loginAttemptId" TEXT NOT NULL,
37+
38+
CONSTRAINT "OAuthCreds_pkey" PRIMARY KEY ("id")
39+
);
40+
41+
-- CreateIndex
42+
CREATE UNIQUE INDEX "OAuthCreds_loginAttemptId_key" ON "OAuthCreds"("loginAttemptId");
43+
44+
-- AddForeignKey
45+
ALTER TABLE "OAuthCreds" ADD CONSTRAINT "OAuthCreds_loginAttemptId_fkey" FOREIGN KEY ("loginAttemptId") REFERENCES "OAuthLoginAttempt"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Please do not edit this file manually
2+
# It should be added in your version-control system (i.e. Git)
3+
provider = "postgresql"

modules/auth_oauth2/db/schema.prisma

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Do not modify this `datasource` block
2+
datasource db {
3+
provider = "postgresql"
4+
url = env("DATABASE_URL")
5+
}
6+
7+
model OAuthUsers {
8+
userId String @db.Uuid
9+
10+
provider String
11+
sub String
12+
createdAt DateTime @default(now()) @db.Timestamp
13+
14+
@@id([provider, userId])
15+
}
16+
17+
model OAuthLoginAttempt {
18+
id String @id @default(uuid())
19+
20+
provider String
21+
state String
22+
codeVerifier String
23+
targetUrl String
24+
25+
createdAt DateTime @default(now())
26+
updatedAt DateTime @updatedAt
27+
completedAt DateTime?
28+
invalidatedAt DateTime?
29+
30+
creds OAuthCreds?
31+
}
32+
33+
model OAuthCreds {
34+
id String @id @default(uuid())
35+
36+
provider String
37+
accessToken String
38+
refreshToken String
39+
expiresAt DateTime
40+
userToken String
41+
42+
createdAt DateTime @default(now())
43+
updatedAt DateTime @updatedAt
44+
45+
loginAttemptId String @unique
46+
loginAttempt OAuthLoginAttempt @relation(fields: [loginAttemptId], references: [id])
47+
}
48+

modules/auth_oauth2/module.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"name": "OAuth Authentication",
3+
"description": "Authenticate users with OAuth 2.0.",
4+
"icon": "key",
5+
"tags": [
6+
"core",
7+
"auth",
8+
"user"
9+
],
10+
"authors": [
11+
"rivet-gg",
12+
"Skyler Calaman"
13+
],
14+
"status": "stable",
15+
"dependencies": {
16+
"users": {},
17+
"tokens": {},
18+
"rate_limit": {}
19+
},
20+
"routes": {
21+
"login_link": {
22+
"name": "Login Link",
23+
"description": "Generate a login link for accessing OpenGB.",
24+
"method": "GET",
25+
"pathPrefix": "/login/"
26+
},
27+
"login_callback": {
28+
"name": "OAuth Redirect Callback",
29+
"description": "Verify a user's OAuth login and create a session.",
30+
"method": "GET",
31+
"pathPrefix": "/callback/"
32+
}
33+
},
34+
"scripts": {},
35+
"errors": {
36+
"invalid_config": {
37+
"name": "Invalid OAuth Provider Configuration"
38+
}
39+
}
40+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import {
2+
RouteContext,
3+
RuntimeError,
4+
RouteRequest,
5+
RouteResponse,
6+
} from "../module.gen.ts";
7+
8+
import { getCodeVerifierFromCookie, getStateFromCookie, getLoginIdFromCookie } from "../utils/trace.ts";
9+
import { getFullConfig } from "../utils/env.ts";
10+
import { getClient } from "../utils/client.ts";
11+
import { getUserUniqueIdentifier } from "../utils/client.ts";
12+
13+
export async function handle(
14+
ctx: RouteContext,
15+
req: RouteRequest,
16+
): Promise<RouteResponse> {
17+
// Max 2 login attempts per IP per minute
18+
ctx.modules.rateLimit.throttlePublic({ requests: 5, period: 60 });
19+
20+
// Ensure that the provider configurations are valid
21+
const config = await getFullConfig(ctx.userConfig);
22+
if (!config) throw new RuntimeError("invalid_config", { statusCode: 500 });
23+
24+
const loginId = getLoginIdFromCookie(ctx);
25+
const codeVerifier = getCodeVerifierFromCookie(ctx);
26+
const state = getStateFromCookie(ctx);
27+
28+
if (!loginId || !codeVerifier || !state) throw new RuntimeError("missing_login_data", { statusCode: 400 });
29+
30+
31+
// Get the login attempt stored in the database
32+
const loginAttempt = await ctx.db.oAuthLoginAttempt.findUnique({
33+
where: { id: loginId, completedAt: null, invalidatedAt: null },
34+
});
35+
36+
if (!loginAttempt) throw new RuntimeError("login_not_found", { statusCode: 400 });
37+
if (loginAttempt.state !== state) throw new RuntimeError("invalid_state", { statusCode: 400 });
38+
if (loginAttempt.codeVerifier !== codeVerifier) throw new RuntimeError("invalid_code_verifier", { statusCode: 400 });
39+
40+
// Get the provider config
41+
const provider = config.providers[loginAttempt.provider];
42+
if (!provider) throw new RuntimeError("invalid_provider", { statusCode: 400 });
43+
44+
// Get the oauth client
45+
const client = getClient(config, provider.name, new URL(req.url));
46+
if (!client.config.redirectUri) throw new RuntimeError("invalid_config", { statusCode: 500 });
47+
48+
49+
// Get the URI that this request was made to
50+
const uri = new URL(req.url);
51+
const uriStr = uri.toString();
52+
53+
// Get the user's tokens and sub
54+
let tokens: Awaited<ReturnType<typeof client.code.getToken>>;
55+
let sub: string;
56+
try {
57+
tokens = await client.code.getToken(uriStr, { state, codeVerifier });
58+
sub = await getUserUniqueIdentifier(tokens.accessToken, provider);
59+
} catch (e) {
60+
console.error(e);
61+
throw new RuntimeError("invalid_oauth_response", { statusCode: 502 });
62+
}
63+
64+
const expiresIn = tokens.expiresIn ?? 3600;
65+
const expiry = new Date(Date.now() + expiresIn);
66+
67+
// Ensure the user is registered with this sub/provider combo
68+
const user = await ctx.db.oAuthUsers.findFirst({
69+
where: {
70+
sub,
71+
provider: loginAttempt.provider,
72+
},
73+
});
74+
75+
let userId: string;
76+
if (user) {
77+
userId = user.userId;
78+
} else {
79+
const { user: newUser } = await ctx.modules.users.createUser({ username: sub });
80+
await ctx.db.oAuthUsers.create({
81+
data: {
82+
sub,
83+
provider: loginAttempt.provider,
84+
userId: newUser.id,
85+
},
86+
});
87+
88+
userId = newUser.id;
89+
}
90+
91+
// Generate a token which the user can use to authenticate with this module
92+
const { token } = await ctx.modules.users.createUserToken({ userId });
93+
94+
// Record the credentials
95+
await ctx.db.oAuthCreds.create({
96+
data: {
97+
loginAttemptId: loginAttempt.id,
98+
provider: provider.name,
99+
accessToken: tokens.accessToken,
100+
refreshToken: tokens.refreshToken ?? "",
101+
userToken: token.token,
102+
expiresAt: expiry,
103+
},
104+
});
105+
106+
107+
const response = RouteResponse.redirect(loginAttempt.targetUrl, 303);
108+
109+
const headers = new Headers(response.headers);
110+
111+
// Clear login session cookies
112+
const expireAttribs = `Path=/; Max-Age=0; SameSite=Lax; Expires=${new Date(0).toUTCString()}`;
113+
headers.append("Set-Cookie", `login_id=EXPIRED; ${expireAttribs}`);
114+
headers.append("Set-Cookie", `code_verifier=EXPIRED; ${expireAttribs}`);
115+
headers.append("Set-Cookie", `state=EXPIRED; ${expireAttribs}`);
116+
117+
// Tell the browser to never cache this page
118+
headers.set("Cache-Control", "no-store");
119+
120+
// Set token cookie
121+
const cookieAttribs = `Path=/; Max-Age=${expiresIn}; SameSite=Lax; Expires=${expiry.toUTCString()}`;
122+
headers.append("Set-Cookie", `token=${token.token}; ${cookieAttribs}`);
123+
124+
return new Response(response.body, { status: response.status, headers });
125+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {
2+
RouteContext,
3+
RuntimeError,
4+
RouteRequest,
5+
RouteResponse,
6+
} from "../module.gen.ts";
7+
8+
import { getFullConfig } from "../utils/env.ts";
9+
import { getClient } from "../utils/client.ts";
10+
import { generateStateStr } from "../utils/state.ts";
11+
12+
// Maybe make different exported functions— `GET`, `POST`, etc?
13+
export async function handle(
14+
ctx: RouteContext,
15+
req: RouteRequest,
16+
): Promise<RouteResponse> {
17+
// Max 5 login attempts per IP per minute
18+
ctx.modules.rateLimit.throttlePublic({ requests: 5, period: 60 });
19+
20+
// Get the data from the RouteRequest query parameters
21+
const url = new URL(req.url);
22+
const provider = url.pathname.split("/").pop();
23+
if (!provider) throw new RuntimeError(
24+
"invalid_req",
25+
{
26+
statusCode: 400,
27+
meta: {
28+
err: "missing provider at end of URL",
29+
path: url.pathname,
30+
params: Object.fromEntries(url.searchParams.entries()),
31+
},
32+
},
33+
);
34+
35+
const targetUrl = url.searchParams.get("targetUrl");
36+
if (!targetUrl) throw new RuntimeError(
37+
"invalid_req",
38+
{
39+
statusCode: 400,
40+
meta: {
41+
err: "missing targetUrl",
42+
path: url.pathname,
43+
params: Object.fromEntries(url.searchParams.entries()),
44+
},
45+
},
46+
);
47+
48+
console.log({ provider, targetUrl });
49+
50+
// Ensure that the provider configurations are valid
51+
const providers = await getFullConfig(ctx.userConfig);
52+
if (!providers) throw new RuntimeError("invalid_config", { statusCode: 500 });
53+
54+
// Get the OAuth2 Client and generate a unique state string
55+
const client = getClient(providers, provider, url);
56+
const state = generateStateStr();
57+
58+
// Get the URI to eventually redirect the user to
59+
const { uri, codeVerifier } = await client.code.getAuthorizationUri({ state });
60+
61+
// Create a login attempt to allow the module to later retrieve the login
62+
// information
63+
const { id: loginId } = await ctx.db.oAuthLoginAttempt.create({
64+
data: {
65+
provider,
66+
targetUrl,
67+
state,
68+
codeVerifier,
69+
},
70+
});
71+
72+
73+
// Build the response
74+
const response = RouteResponse.redirect(
75+
uri.toString(),
76+
303,
77+
);
78+
79+
const headers = new Headers(response.headers);
80+
81+
// Set login session cookies
82+
const cookieOptions = `Path=/; SameSite=Lax; Max-Age=300; Expires=${new Date(Date.now() + 300 * 1000).toUTCString()}`;
83+
headers.append("Set-Cookie", `login_id=${encodeURIComponent(loginId)}; ${cookieOptions}`);
84+
headers.append("Set-Cookie", `code_verifier=${encodeURIComponent(codeVerifier)}; ${cookieOptions}`);
85+
headers.append("Set-Cookie", `state=${encodeURIComponent(state)}; ${cookieOptions}`);
86+
87+
// Tell the browser to never cache this page
88+
headers.set("Cache-Control", "no-store");
89+
90+
return new Response(response.body, { status: response.status, headers });
91+
}

0 commit comments

Comments
 (0)