Skip to content

Commit 0c7d8d0

Browse files
committed
refactor(server): replace elysia-oauth2 with custom OAuth implementation
Replace the incompatible elysia-oauth2 dependency with a custom OAuth2 implementation that provides: - Generic OAuth2 plugin architecture supporting any OAuth2 provider - Built-in GitHub OAuth provider as default - CSRF protection via state parameter validation - Type-safe implementation with full TypeScript support - Simplified API without external dependencies Changes: - Add server/src/utils/oauth.ts with OAuth2 implementation - Update server/src/setup.ts to use new OAuth plugin - Update server/src/services/user.ts with state validation - Remove elysia-oauth2 from package.json dependencies
1 parent a0eddbc commit 0c7d8d0

5 files changed

Lines changed: 190 additions & 24 deletions

File tree

bun.lockb

-424 Bytes
Binary file not shown.

server/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
"arctic": "^3.7.0",
3434
"drizzle-orm": "^0.30.10",
3535
"elysia": "^1.4.22",
36-
"elysia-oauth2": "2.1.0",
3736
"feed": "^4.2.2",
3837
"jose": "^5.3.0",
3938
"openapi-types": "^12.1.3",

server/src/services/user.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,36 @@
11
import { eq } from "drizzle-orm";
22
import { t } from "elysia";
3-
import { URL } from "url";
43
import { users } from "../db/schema";
54
import base from "../base";
65

76
export const UserService = base()
87
.group('/user', (group) =>
98
group
10-
.get("/github", ({ oauth2, headers: { referer }, cookie: { redirect_to }, store: { env } }) => {
9+
.get("/github", ({ oauth2, set, headers: { referer }, cookie: { redirect_to } }) => {
1110
if (!referer) {
1211
return 'Referer not found'
1312
}
14-
const referer_url = new URL(referer)
15-
redirect_to.value = `${referer_url.protocol}//${referer_url.host}`
16-
return oauth2?.redirect("GitHub", ["read:user"])
13+
redirect_to.value = `${referer}`
14+
set.headers['Location'] = oauth2.createRedirectUrl("GitHub")
15+
set.status = 302
1716
})
18-
.get("/github/callback", async ({ jwt, oauth2, set, store, query, cookie: { token, redirect_to, state } }) => {
17+
.get("/github/callback", async ({ jwt, oauth2, set, store, query, cookie: { token, redirect_to } }) => {
1918
const { db } = store;
2019

21-
console.log('state', state.value)
2220
console.log('p_state', query.state)
2321

24-
const gh_token = await oauth2?.authorize("GitHub");
22+
// Verify state to prevent CSRF attacks
23+
if (!oauth2.verifyState(query.state)) {
24+
set.status = 400;
25+
return 'Invalid state parameter';
26+
}
27+
28+
oauth2.removeState(query.state);
29+
const gh_token = await oauth2.authorize("GitHub", query.code);
30+
if (!gh_token) {
31+
set.status = 400;
32+
return 'Failed to authorize with GitHub';
33+
}
2534
// request https://api.github.com/user for user info
2635
const response = await fetch("https://api.github.com/user", {
2736
headers: {
@@ -74,12 +83,9 @@ export const UserService = base()
7483
}
7584
}
7685
});
77-
const redirect_host = redirect_to.value || ""
78-
const redirect_url = (`${redirect_host}/callback?token=${token.value}`);
79-
set.headers = {
80-
'Content-Type': 'text/html',
81-
}
82-
set.redirect = redirect_url
86+
const redirect_url = `${redirect_to.value}`
87+
set.headers['Location'] = redirect_url
88+
set.status = 302
8389
}, {
8490
query: t.Object({
8591
state: t.String(),

server/src/setup.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { eq } from "drizzle-orm";
22
import { Elysia, t } from "elysia";
3-
import { oauth2 } from "elysia-oauth2";
43
import type { DB } from "./context";
54
import { users } from "./db/schema";
65
import jwt from "./utils/jwt";
76
import { CacheImpl } from "./utils/cache";
87
import { drizzle } from "drizzle-orm/d1";
98
import * as schema from './db/schema';
9+
import { createOAuthPlugin, GitHubProvider } from "./utils/oauth";
1010

1111

1212
const anyUser = async (db: DB) => (await db.query.users.findMany())?.length > 0
@@ -40,12 +40,11 @@ export function createSetupPlugin(env: Env) {
4040
throw new Error('Please set JWT_SECRET');
4141
}
4242

43-
const oauth = oauth2({
44-
GitHub: [
45-
gh_client_id,
46-
gh_client_secret,
47-
null
48-
],
43+
const oauth = createOAuthPlugin({
44+
GitHub: new GitHubProvider({
45+
clientId: gh_client_id,
46+
clientSecret: gh_client_secret,
47+
}),
4948
});
5049

5150
return new Elysia()
@@ -66,7 +65,7 @@ export function createSetupPlugin(env: Env) {
6665
})
6766
})
6867
)
69-
.derive({ as: 'global' }, async ({ headers, jwt, store: { db }, oauth2 }) => {
68+
.derive({ as: 'global' }, async ({ headers, jwt, store: { db } }) => {
7069
const authorization = headers['authorization']
7170
if (!authorization) {
7271
return {};
@@ -85,7 +84,6 @@ export function createSetupPlugin(env: Env) {
8584
uid: user.id,
8685
username: user.username,
8786
admin: user.permission === 1,
88-
oauth2: oauth2
8987
}
9088
})
9189
}

server/src/utils/oauth.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { Elysia } from "elysia";
2+
3+
export interface OAuthProvider {
4+
name: string;
5+
clientId: string;
6+
clientSecret: string;
7+
redirectUri?: string;
8+
authorizeUrl: string;
9+
tokenUrl: string;
10+
scopes: string[];
11+
}
12+
13+
export interface OAuthToken {
14+
accessToken: string;
15+
tokenType: string;
16+
scope?: string;
17+
expiresIn?: number;
18+
refreshToken?: string;
19+
}
20+
21+
export interface GitHubConfig {
22+
clientId: string;
23+
clientSecret: string;
24+
redirectUri?: string;
25+
}
26+
27+
export class GitHubProvider implements OAuthProvider {
28+
name = "GitHub";
29+
clientId: string;
30+
clientSecret: string;
31+
redirectUri?: string;
32+
authorizeUrl = "https://github.com/login/oauth/authorize";
33+
tokenUrl = "https://github.com/login/oauth/access_token";
34+
scopes: string[] = ["read:user"];
35+
36+
constructor(config: GitHubConfig) {
37+
this.clientId = config.clientId;
38+
this.clientSecret = config.clientSecret;
39+
this.redirectUri = config.redirectUri;
40+
}
41+
}
42+
43+
export interface OAuth2Methods {
44+
redirect: (providerName: string, scopes?: string[]) => Response;
45+
authorize: (providerName: string, code?: string) => Promise<OAuthToken>;
46+
verifyState: (state: string) => boolean;
47+
getStateData: (state: string) => { provider: string; timestamp: number } | undefined;
48+
removeState: (state: string) => void;
49+
}
50+
51+
export function createOAuthPlugin(providers: Record<string, OAuthProvider>) {
52+
const stateStore = new Map<string, { provider: string; timestamp: number }>();
53+
54+
// Clean up expired states (older than 10 minutes)
55+
const cleanupExpiredStates = () => {
56+
const now = Date.now();
57+
const expiryTime = 10 * 60 * 1000; // 10 minutes
58+
for (const [state, data] of stateStore.entries()) {
59+
if (now - data.timestamp > expiryTime) {
60+
stateStore.delete(state);
61+
}
62+
}
63+
};
64+
65+
// Generate random state string
66+
const generateState = (): string => {
67+
const array = new Uint8Array(32);
68+
crypto.getRandomValues(array);
69+
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("");
70+
};
71+
72+
return new Elysia({ name: "oauth2" })
73+
.decorate("oauth2", {
74+
createRedirectUrl: (providerName: string): string => {
75+
const provider = providers[providerName];
76+
if (!provider) {
77+
throw new Error(`OAuth provider "${providerName}" not found`);
78+
}
79+
80+
cleanupExpiredStates();
81+
82+
const state = generateState();
83+
stateStore.set(state, {
84+
provider: providerName,
85+
timestamp: Date.now(),
86+
});
87+
88+
const params = new URLSearchParams({
89+
client_id: provider.clientId,
90+
state: state,
91+
});
92+
93+
// if (provider.redirectUri) {
94+
// params.set("redirect_uri", provider.redirectUri);
95+
// }
96+
97+
return `${provider.authorizeUrl}?${params.toString()}`;
98+
},
99+
100+
authorize: async (providerName: string, code?: string): Promise<OAuthToken> => {
101+
const provider = providers[providerName];
102+
if (!provider) {
103+
throw new Error(`OAuth provider "${providerName}" not found`);
104+
}
105+
106+
if (!code) {
107+
throw new Error("Authorization code is required");
108+
}
109+
110+
const params = new URLSearchParams({
111+
client_id: provider.clientId,
112+
client_secret: provider.clientSecret,
113+
code: code,
114+
});
115+
116+
if (provider.redirectUri) {
117+
params.set("redirect_uri", provider.redirectUri);
118+
}
119+
120+
const response = await fetch(provider.tokenUrl, {
121+
method: "POST",
122+
headers: {
123+
"Content-Type": "application/x-www-form-urlencoded",
124+
Accept: "application/json",
125+
},
126+
body: params.toString(),
127+
});
128+
129+
if (!response.ok) {
130+
throw new Error(`Failed to exchange code for token: ${response.statusText}`);
131+
}
132+
133+
const data = await response.json() as {
134+
access_token: string;
135+
token_type?: string;
136+
scope?: string;
137+
expires_in?: number;
138+
refresh_token?: string;
139+
};
140+
141+
return {
142+
accessToken: data.access_token,
143+
tokenType: data.token_type || "Bearer",
144+
scope: data.scope,
145+
expiresIn: data.expires_in,
146+
refreshToken: data.refresh_token,
147+
};
148+
},
149+
150+
verifyState: (state: string): boolean => {
151+
cleanupExpiredStates();
152+
return stateStore.has(state);
153+
},
154+
155+
getStateData: (state: string) => {
156+
return stateStore.get(state);
157+
},
158+
159+
removeState: (state: string) => {
160+
stateStore.delete(state);
161+
},
162+
});
163+
}

0 commit comments

Comments
 (0)