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

Commit 7767658

Browse files
Blckbrry-PiNathanFlurry
authored andcommitted
feat: Create an OAuth2 module for authenticating users
1 parent 99db8d5 commit 7767658

File tree

18 files changed

+846
-28
lines changed

18 files changed

+846
-28
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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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+
"tokenData" JSONB,
9+
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
"expiresAt" TIMESTAMP(3) NOT NULL,
11+
"completedAt" TIMESTAMP(3),
12+
"invalidatedAt" TIMESTAMP(3),
13+
14+
CONSTRAINT "LoginAttempts_pkey" PRIMARY KEY ("id")
15+
);
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Do not modify this `datasource` block
2+
datasource db {
3+
provider = "postgresql"
4+
url = env("DATABASE_URL")
5+
}
6+
7+
model LoginAttempts {
8+
id String @id @default(uuid())
9+
10+
providerId String
11+
state String
12+
codeVerifier String
13+
14+
identifier String?
15+
tokenData Json?
16+
17+
startedAt DateTime @default(now())
18+
expiresAt DateTime
19+
completedAt DateTime?
20+
invalidatedAt DateTime?
21+
}

modules/auth_oauth2/module.json

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
{
2+
"name": "OAuth2 Authentication Provider",
3+
"description": "Authenticate users with OAuth 2.0.",
4+
"icon": "key",
5+
"tags": [
6+
"core",
7+
"user",
8+
"auth"
9+
],
10+
"authors": [
11+
"rivet-gg",
12+
"Skyler Calaman"
13+
],
14+
"status": "beta",
15+
"dependencies": {
16+
"rate_limit": {},
17+
"identities": {},
18+
"users": {},
19+
"tokens": {}
20+
},
21+
"routes": {
22+
"login_callback": {
23+
"name": "OAuth Redirect Callback",
24+
"description": "Verify a user's OAuth login and create a session.",
25+
"method": "GET",
26+
"pathPrefix": "/callback/"
27+
}
28+
},
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_login_status": {
36+
"name": "Get Login 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+
"complete_add_to_user": {
41+
"name": "Complete Flow and Add OAuth Login to User",
42+
"description": "Use a finished OAuth flow to add the OAuth login to an already-authenticated users.",
43+
"public": true
44+
},
45+
"complete_login_to_user": {
46+
"name": "Complete Flow and Login to or Create User with OAuth",
47+
"description": "Use a finished OAuth flow to login to a user, creating a new one if it doesn't exist.",
48+
"public": true
49+
}
50+
},
51+
"errors": {
52+
"already_friends": {
53+
"name": "Already Friends"
54+
},
55+
"friend_request_not_found": {
56+
"name": "Friend Request Not Found"
57+
},
58+
"friend_request_already_exists": {
59+
"name": "Friend Request Already Exists"
60+
},
61+
"not_friend_request_recipient": {
62+
"name": "Not Friend Request Recipient"
63+
},
64+
"friend_request_already_accepted": {
65+
"name": "Friend Request Already Accepted"
66+
},
67+
"friend_request_already_declined": {
68+
"name": "Friend Request Already Declined"
69+
},
70+
"cannot_send_to_self": {
71+
"name": "Cannot Send to Self"
72+
}
73+
}
74+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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 { getUserUniqueIdentifier } from "../utils/client.ts";
11+
import { Tokens } from "https://deno.land/x/oauth2_client@v1.0.2/mod.ts";
12+
13+
import { compareConstantTime, stateToDataStr } from "../utils/state.ts";
14+
import { OAUTH_DONE_HTML } from "../utils/pages.ts";
15+
16+
export async function handle(
17+
ctx: RouteContext,
18+
req: RouteRequest,
19+
): Promise<RouteResponse> {
20+
// Max 5 login attempts per IP per minute
21+
ctx.modules.rateLimit.throttlePublic({ requests: 5, period: 60 });
22+
23+
// Ensure that the provider configurations are valid
24+
const config = await getFullConfig(ctx.config);
25+
if (!config) throw new RuntimeError("invalid_config", { statusCode: 500 });
26+
27+
// Get the URI that this request was made to
28+
const uri = new URL(req.url);
29+
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+
}
35+
36+
// Extract the data from the state
37+
const stateData = await stateToDataStr(config.oauthSecret, redirectedState);
38+
const { flowId, providerId } = JSON.parse(stateData);
39+
40+
// Get the login attempt stored in the database
41+
const loginAttempt = await ctx.db.loginAttempts.findUnique({
42+
where: {
43+
id: flowId,
44+
},
45+
});
46+
if (!loginAttempt) throw new RuntimeError("login_not_found", { 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;
65+
66+
// Get the provider config
67+
const provider = config.providers[providerId];
68+
if (!provider) throw new RuntimeError("invalid_provider", { statusCode: 400 });
69+
70+
// Get the oauth client
71+
const client = getClient(config, provider.name);
72+
if (!client.config.redirectUri) throw new RuntimeError("invalid_config", { statusCode: 500 });
73+
74+
// Get the user's tokens and sub
75+
let tokens: Tokens;
76+
let ident: string;
77+
try {
78+
tokens = await client.code.getToken(uri.toString(), { state, codeVerifier });
79+
ident = await getUserUniqueIdentifier(tokens.accessToken, provider);
80+
} catch (e) {
81+
console.error(e);
82+
throw new RuntimeError("invalid_oauth_response", { statusCode: 502 });
83+
}
84+
85+
// Update the login attempt
86+
await ctx.db.loginAttempts.update({
87+
where: {
88+
id: flowId,
89+
},
90+
data: {
91+
identifier: ident,
92+
tokenData: { ...tokens },
93+
completedAt: new Date(),
94+
},
95+
});
96+
97+
return new RouteResponse(
98+
OAUTH_DONE_HTML,
99+
{
100+
status: 200,
101+
headers: {
102+
"Content-Type": "text/html",
103+
},
104+
},
105+
);
106+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { RuntimeError, ScriptContext } from "../module.gen.ts";
2+
3+
export interface Request {
4+
flowToken: string;
5+
userToken: string;
6+
}
7+
8+
export type Response = ReturnType<ScriptContext["modules"]["identities"]["link"]>;
9+
10+
export async function run(
11+
ctx: ScriptContext,
12+
req: Request,
13+
): Promise<Response> {
14+
await ctx.modules.rateLimit.throttlePublic({});
15+
16+
if (!req.flowToken) throw new RuntimeError("missing_token", { statusCode: 400 });
17+
18+
const { tokens: [flowToken] } = await ctx.modules.tokens.fetchByToken({ tokens: [req.flowToken] });
19+
if (!flowToken) {
20+
throw new RuntimeError("invalid_token", { statusCode: 400 });
21+
}
22+
if (new Date(flowToken.expireAt ?? 0) < new Date()) {
23+
throw new RuntimeError("expired_token", { statusCode: 400 });
24+
}
25+
26+
const flowId = flowToken.meta.flowId;
27+
if (!flowId) throw new RuntimeError("invalid_token", { statusCode: 400 });
28+
29+
const flow = await ctx.db.loginAttempts.findFirst({
30+
where: {
31+
id: flowId,
32+
}
33+
});
34+
if (!flow) throw new RuntimeError("invalid_token", { statusCode: 400 });
35+
36+
if (!flow.identifier || !flow.tokenData) {
37+
throw new RuntimeError("flow_not_complete", { statusCode: 400 });
38+
}
39+
40+
await ctx.modules.users.authenticateToken({ userToken: req.userToken });
41+
42+
const tokenData = flow.tokenData;
43+
if (!tokenData) {
44+
throw new RuntimeError("internal_error", { statusCode: 500 });
45+
}
46+
if (typeof tokenData !== "object") {
47+
throw new RuntimeError("internal_error", { statusCode: 500 });
48+
}
49+
if (Array.isArray(tokenData)) {
50+
throw new RuntimeError("internal_error", { statusCode: 500 });
51+
}
52+
53+
return await ctx.modules.identities.link({
54+
userToken: req.userToken,
55+
info: {
56+
identityType: "oauth2",
57+
identityId: flow.providerId,
58+
},
59+
uniqueData: {
60+
identifier: flow.identifier,
61+
},
62+
additionalData: tokenData,
63+
});
64+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { RuntimeError, ScriptContext } from "../module.gen.ts";
2+
3+
export interface Request {
4+
flowToken: string;
5+
}
6+
7+
export type Response = ReturnType<ScriptContext["modules"]["identities"]["signInOrSignUp"]>;
8+
9+
export async function run(
10+
ctx: ScriptContext,
11+
req: Request,
12+
): Promise<Response> {
13+
await ctx.modules.rateLimit.throttlePublic({});
14+
15+
if (!req.flowToken) throw new RuntimeError("missing_token", { statusCode: 400 });
16+
17+
const { tokens: [flowToken] } = await ctx.modules.tokens.fetchByToken({ tokens: [req.flowToken] });
18+
if (!flowToken) {
19+
throw new RuntimeError("invalid_token", { statusCode: 400 });
20+
}
21+
if (new Date(flowToken.expireAt ?? 0) < new Date()) {
22+
throw new RuntimeError("expired_token", { statusCode: 400 });
23+
}
24+
25+
const flowId = flowToken.meta.flowId;
26+
if (!flowId) throw new RuntimeError("invalid_token", { statusCode: 400 });
27+
28+
const flow = await ctx.db.loginAttempts.findFirst({
29+
where: {
30+
id: flowId,
31+
}
32+
});
33+
if (!flow) throw new RuntimeError("invalid_token", { statusCode: 400 });
34+
35+
if (!flow.identifier || !flow.tokenData) {
36+
throw new RuntimeError("flow_not_complete", { statusCode: 400 });
37+
}
38+
39+
const tokenData = flow.tokenData;
40+
if (!tokenData) {
41+
throw new RuntimeError("internal_error", { statusCode: 500 });
42+
}
43+
if (typeof tokenData !== "object") {
44+
throw new RuntimeError("internal_error", { statusCode: 500 });
45+
}
46+
if (Array.isArray(tokenData)) {
47+
throw new RuntimeError("internal_error", { statusCode: 500 });
48+
}
49+
50+
return await ctx.modules.identities.signInOrSignUp({
51+
info: {
52+
identityType: "oauth2",
53+
identityId: flow.providerId,
54+
},
55+
uniqueData: {
56+
identifier: flow.identifier,
57+
},
58+
additionalData: tokenData,
59+
});
60+
}

0 commit comments

Comments
 (0)