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

Commit 4d76ab6

Browse files
committed
feat: Give the auth_oauth2 module its own flow management
1 parent 7d177f8 commit 4d76ab6

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,122 +5,101 @@ 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
import { Tokens } from "https://deno.land/x/oauth2_client@v1.0.2/mod.ts";
1312

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

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

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

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

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

3240
// Get the login attempt stored in the database
33-
const loginAttempt = await ctx.db.oAuthLoginAttempt.findUnique({
34-
where: { id: loginId, completedAt: null, invalidatedAt: null },
41+
const loginAttempt = await ctx.db.loginAttempts.findUnique({
42+
where: {
43+
id: flowId,
44+
},
3545
});
36-
3746
if (!loginAttempt) throw new RuntimeError("login_not_found", { statusCode: 400 });
38-
if (loginAttempt.state !== state) throw new RuntimeError("invalid_state", { statusCode: 400 });
39-
if (loginAttempt.codeVerifier !== codeVerifier) throw new RuntimeError("invalid_code_verifier", { statusCode: 400 });
47+
48+
// Check if the login attempt is valid
49+
if (loginAttempt.completedAt) {
50+
throw new RuntimeError("login_already_completed", { statusCode: 400 });
51+
}
52+
if (loginAttempt.invalidatedAt) {
53+
throw new RuntimeError("login_cancelled", { statusCode: 400 });
54+
}
55+
if (new Date(loginAttempt.expiresAt) < new Date()) {
56+
throw new RuntimeError("login_expired", { statusCode: 400 });
57+
}
58+
59+
// Check if the provider ID and state match
60+
const providerIdMatch = compareConstantTime(loginAttempt.providerId, providerId);
61+
const stateMatch = compareConstantTime(loginAttempt.state, redirectedState);
62+
if (!providerIdMatch || !stateMatch) throw new RuntimeError("invalid_state", { statusCode: 400 });
63+
64+
const { state, codeVerifier } = loginAttempt;
4065

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

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

49-
50-
// Get the URI that this request was made to
51-
const uri = new URL(req.url);
52-
const uriStr = uri.toString();
53-
5474
// Get the user's tokens and sub
5575
let tokens: Tokens;
56-
let sub: string;
76+
let ident: string;
5777
try {
58-
tokens = await client.code.getToken(uriStr, { state, codeVerifier });
59-
sub = await getUserUniqueIdentifier(tokens.accessToken, provider);
78+
tokens = await client.code.getToken(uri.toString(), { state, codeVerifier });
79+
ident = await getUserUniqueIdentifier(tokens.accessToken, provider);
6080
} catch (e) {
6181
console.error(e);
6282
throw new RuntimeError("invalid_oauth_response", { statusCode: 502 });
6383
}
6484

65-
const expiresIn = tokens.expiresIn ?? 3600;
66-
const expiry = new Date(Date.now() + expiresIn);
67-
68-
// Ensure the user is registered with this sub/provider combo
69-
const user = await ctx.db.oAuthUsers.findFirst({
85+
// Update the login attempt
86+
await ctx.db.loginAttempts.update({
7087
where: {
71-
sub,
72-
provider: loginAttempt.provider,
88+
id: flowId,
7389
},
74-
});
75-
76-
let userId: string;
77-
if (user) {
78-
userId = user.userId;
79-
} else {
80-
const { user: newUser } = await ctx.modules.users.createUser({ username: sub });
81-
await ctx.db.oAuthUsers.create({
82-
data: {
83-
sub,
84-
provider: loginAttempt.provider,
85-
userId: newUser.id,
86-
},
87-
});
88-
89-
userId = newUser.id;
90-
}
91-
92-
// Generate a token which the user can use to authenticate with this module
93-
const { token } = await ctx.modules.users.createUserToken({ userId });
94-
95-
// Record the credentials
96-
await ctx.db.oAuthCreds.create({
9790
data: {
98-
loginAttemptId: loginAttempt.id,
99-
provider: provider.name,
100-
accessToken: tokens.accessToken,
101-
refreshToken: tokens.refreshToken ?? "",
102-
userToken: token.token,
103-
expiresAt: expiry,
91+
identifier: ident,
92+
completedAt: new Date(),
10493
},
10594
});
10695

107-
108-
const response = RouteResponse.redirect(loginAttempt.targetUrl, 303);
109-
110-
const headers = new Headers(response.headers);
111-
112-
// Clear login session cookies
113-
const expireAttribs = `Path=/; Max-Age=0; SameSite=Lax; Expires=${new Date(0).toUTCString()}`;
114-
headers.append("Set-Cookie", `login_id=EXPIRED; ${expireAttribs}`);
115-
headers.append("Set-Cookie", `code_verifier=EXPIRED; ${expireAttribs}`);
116-
headers.append("Set-Cookie", `state=EXPIRED; ${expireAttribs}`);
117-
118-
// Tell the browser to never cache this page
119-
headers.set("Cache-Control", "no-store");
120-
121-
// Set token cookie
122-
const cookieAttribs = `Path=/; Max-Age=${expiresIn}; SameSite=Lax; Expires=${expiry.toUTCString()}`;
123-
headers.append("Set-Cookie", `token=${token.token}; ${cookieAttribs}`);
124-
125-
return new Response(response.body, { status: response.status, headers });
96+
return new RouteResponse(
97+
OAUTH_DONE_HTML,
98+
{
99+
status: 200,
100+
headers: {
101+
"Content-Type": "text/html",
102+
},
103+
},
104+
);
126105
}

0 commit comments

Comments
 (0)