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

Commit d179f19

Browse files
committed
feat: Give the auth_oauth2 module its own flow management
1 parent ae1fc49 commit d179f19

File tree

13 files changed

+490
-265
lines changed

13 files changed

+490
-265
lines changed

modules/auth_oauth2/db/migrations/20240508161825_/migration.sql

Lines changed: 0 additions & 45 deletions
This file was deleted.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- CreateTable
2+
CREATE TABLE "LoginAttempts" (
3+
"id" TEXT NOT NULL,
4+
"providerId" TEXT NOT NULL,
5+
"state" TEXT NOT NULL,
6+
"codeVerifier" TEXT NOT NULL,
7+
"identifier" TEXT,
8+
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
"expiresAt" TIMESTAMP(3) NOT NULL,
10+
"completedAt" TIMESTAMP(3),
11+
"invalidatedAt" TIMESTAMP(3),
12+
13+
CONSTRAINT "LoginAttempts_pkey" PRIMARY KEY ("id")
14+
);

modules/auth_oauth2/db/schema.prisma

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,17 @@ datasource db {
44
url = env("DATABASE_URL")
55
}
66

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 {
7+
model LoginAttempts {
188
id String @id @default(uuid())
199
20-
provider String
10+
providerId String
2111
state String
2212
codeVerifier String
23-
targetUrl String
2413
25-
createdAt DateTime @default(now())
26-
updatedAt DateTime @updatedAt
27-
completedAt DateTime?
28-
invalidatedAt DateTime?
29-
30-
creds OAuthCreds?
31-
}
14+
identifier String?
3215
33-
model OAuthCreds {
34-
id String @id @default(uuid())
35-
36-
provider String
37-
accessToken String
38-
refreshToken String
16+
startedAt DateTime @default(now())
3917
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])
18+
completedAt DateTime?
19+
invalidatedAt DateTime?
4720
}
48-

modules/auth_oauth2/module.json

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,30 @@
1414
"status": "beta",
1515
"dependencies": {
1616
"rate_limit": {},
17+
"auth_providers": {},
1718
"users": {},
1819
"tokens": {}
1920
},
2021
"routes": {
21-
"login_link": {
22-
"name": "Login Link",
23-
"description": "Generate a login link for accessing OpenGB.",
24-
"method": "GET",
25-
"pathPrefix": "/login/"
26-
},
2722
"login_callback": {
2823
"name": "OAuth Redirect Callback",
2924
"description": "Verify a user's OAuth login and create a session.",
3025
"method": "GET",
3126
"pathPrefix": "/callback/"
3227
}
3328
},
34-
"scripts": {},
29+
"scripts": {
30+
"start_login": {
31+
"name": "Start Login",
32+
"description": "Start the OAuth login process. Returns a URL to redirect the user to and a flow token.",
33+
"public": true
34+
},
35+
"get_status": {
36+
"name": "Get Status",
37+
"description": "Check the status of a OAuth login using the flow token. Returns the status of the login flow.",
38+
"public": true
39+
}
40+
},
3541
"errors": {
3642
"already_friends": {
3743
"name": "Already Friends"

modules/auth_oauth2/routes/login_callback.ts

Lines changed: 56 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -5,121 +5,100 @@ import {
55
RouteResponse,
66
} from "../module.gen.ts";
77

8-
import { getCodeVerifierFromCookie, getStateFromCookie, getLoginIdFromCookie } from "../utils/trace.ts";
98
import { getFullConfig } from "../utils/env.ts";
109
import { getClient } from "../utils/client.ts";
1110
import { getUserUniqueIdentifier } from "../utils/client.ts";
1211

12+
import { compareConstantTime, stateToDataStr } from "../utils/state.ts";
13+
import { OAUTH_DONE_HTML } from "../utils/pages.ts";
14+
1315
export async function handle(
1416
ctx: RouteContext,
1517
req: RouteRequest,
1618
): Promise<RouteResponse> {
17-
// Max 2 login attempts per IP per minute
19+
// Max 5 login attempts per IP per minute
1820
ctx.modules.rateLimit.throttlePublic({ requests: 5, period: 60 });
1921

2022
// Ensure that the provider configurations are valid
21-
const config = await getFullConfig(ctx.userConfig);
23+
const config = await getFullConfig(ctx.config);
2224
if (!config) throw new RuntimeError("invalid_config", { statusCode: 500 });
2325

24-
const loginId = getLoginIdFromCookie(ctx);
25-
const codeVerifier = getCodeVerifierFromCookie(ctx);
26-
const state = getStateFromCookie(ctx);
26+
// Get the URI that this request was made to
27+
const uri = new URL(req.url);
2728

28-
if (!loginId || !codeVerifier || !state) throw new RuntimeError("missing_login_data", { statusCode: 400 });
29+
// Get the state from the URI
30+
const redirectedState = uri.searchParams.get("state");
31+
if (!redirectedState) {
32+
throw new RuntimeError("missing_state", { statusCode: 400 });
33+
}
2934

35+
// Extract the data from the state
36+
const stateData = await stateToDataStr(config.oauthSecret, redirectedState);
37+
const { flowId, providerId } = JSON.parse(stateData);
3038

3139
// Get the login attempt stored in the database
32-
const loginAttempt = await ctx.db.oAuthLoginAttempt.findUnique({
33-
where: { id: loginId, completedAt: null, invalidatedAt: null },
40+
const loginAttempt = await ctx.db.loginAttempts.findUnique({
41+
where: {
42+
id: flowId,
43+
},
3444
});
35-
3645
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 });
46+
47+
// Check if the login attempt is valid
48+
if (loginAttempt.completedAt) {
49+
throw new RuntimeError("login_already_completed", { statusCode: 400 });
50+
}
51+
if (loginAttempt.invalidatedAt) {
52+
throw new RuntimeError("login_cancelled", { statusCode: 400 });
53+
}
54+
if (new Date(loginAttempt.expiresAt) < new Date()) {
55+
throw new RuntimeError("login_expired", { statusCode: 400 });
56+
}
57+
58+
// Check if the provider ID and state match
59+
const providerIdMatch = compareConstantTime(loginAttempt.providerId, providerId);
60+
const stateMatch = compareConstantTime(loginAttempt.state, redirectedState);
61+
if (!providerIdMatch || !stateMatch) throw new RuntimeError("invalid_state", { statusCode: 400 });
62+
63+
const { state, codeVerifier } = loginAttempt;
3964

4065
// Get the provider config
41-
const provider = config.providers[loginAttempt.provider];
66+
const provider = config.providers[providerId];
4267
if (!provider) throw new RuntimeError("invalid_provider", { statusCode: 400 });
4368

4469
// Get the oauth client
45-
const client = getClient(config, provider.name, new URL(req.url));
70+
const client = getClient(config, provider.name);
4671
if (!client.config.redirectUri) throw new RuntimeError("invalid_config", { statusCode: 500 });
4772

48-
49-
// Get the URI that this request was made to
50-
const uri = new URL(req.url);
51-
const uriStr = uri.toString();
52-
5373
// Get the user's tokens and sub
5474
let tokens: Awaited<ReturnType<typeof client.code.getToken>>;
55-
let sub: string;
75+
let ident: string;
5676
try {
57-
tokens = await client.code.getToken(uriStr, { state, codeVerifier });
58-
sub = await getUserUniqueIdentifier(tokens.accessToken, provider);
77+
tokens = await client.code.getToken(uri.toString(), { state, codeVerifier });
78+
ident = await getUserUniqueIdentifier(tokens.accessToken, provider);
5979
} catch (e) {
6080
console.error(e);
6181
throw new RuntimeError("invalid_oauth_response", { statusCode: 502 });
6282
}
6383

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({
84+
// Update the login attempt
85+
await ctx.db.loginAttempts.update({
6986
where: {
70-
sub,
71-
provider: loginAttempt.provider,
87+
id: flowId,
7288
},
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({
9689
data: {
97-
loginAttemptId: loginAttempt.id,
98-
provider: provider.name,
99-
accessToken: tokens.accessToken,
100-
refreshToken: tokens.refreshToken ?? "",
101-
userToken: token.token,
102-
expiresAt: expiry,
90+
identifier: ident,
91+
completedAt: new Date(),
10392
},
10493
});
10594

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 });
95+
return new RouteResponse(
96+
OAUTH_DONE_HTML,
97+
{
98+
status: 200,
99+
headers: {
100+
"Content-Type": "text/html",
101+
},
102+
},
103+
);
125104
}

0 commit comments

Comments
 (0)