Skip to content

Commit 758db71

Browse files
committed
Refactor OAuth implementation: consolidate token refresh logic into a unified OAuthProvider class, implement provider-specific classes for Google, Discord, LinkedIn, Reddit, and GitHub, and add comprehensive tests for provider registry and token refresh functionality.
1 parent 6d62420 commit 758db71

17 files changed

Lines changed: 1302 additions & 1681 deletions

apps/api/src/oauth/OAuthProvider.ts

Lines changed: 513 additions & 0 deletions
Large diffs are not rendered by default.

apps/api/src/oauth/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Registry functions
2+
export type { ProviderName } from "./registry";
3+
export { getAllProviders, getProvider, isValidProvider } from "./registry";
4+
5+
// Base class
6+
export { OAuthProvider } from "./OAuthProvider";
7+
8+
// Types
9+
export type {
10+
DiscordToken,
11+
DiscordUser,
12+
GitHubToken,
13+
GitHubUser,
14+
GoogleToken,
15+
GoogleUser,
16+
LinkedInToken,
17+
LinkedInUser,
18+
OAuthState,
19+
RedditToken,
20+
RedditUser,
21+
ValidatedState,
22+
} from "./types";
23+
24+
// Errors
25+
export { OAuthError } from "./types";

apps/api/src/oauth/oauth.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
getAllProviders,
5+
getProvider,
6+
isValidProvider,
7+
OAuthError,
8+
} from "./index";
9+
10+
describe("OAuth Module", () => {
11+
describe("Provider Registry", () => {
12+
it("should return provider for valid provider names", () => {
13+
expect(getProvider("google-mail")).toBeDefined();
14+
expect(getProvider("google-calendar")).toBeDefined();
15+
expect(getProvider("discord")).toBeDefined();
16+
expect(getProvider("linkedin")).toBeDefined();
17+
expect(getProvider("reddit")).toBeDefined();
18+
expect(getProvider("github")).toBeDefined();
19+
});
20+
21+
it("should throw error for unknown provider", () => {
22+
expect(() => getProvider("unknown")).toThrow("Unknown OAuth provider");
23+
});
24+
25+
it("should validate provider names", () => {
26+
expect(isValidProvider("google-mail")).toBe(true);
27+
expect(isValidProvider("discord")).toBe(true);
28+
expect(isValidProvider("unknown")).toBe(false);
29+
});
30+
31+
it("should return all providers", () => {
32+
const providers = getAllProviders();
33+
expect(providers).toHaveLength(6);
34+
});
35+
});
36+
37+
describe("Provider Configuration", () => {
38+
it("should have correct refresh configuration for Google providers", () => {
39+
const googleMail = getProvider("google-mail");
40+
const googleCalendar = getProvider("google-calendar");
41+
42+
expect(googleMail.refreshEnabled).toBe(true);
43+
expect(googleCalendar.refreshEnabled).toBe(true);
44+
});
45+
46+
it("should have correct refresh configuration for Discord", () => {
47+
const discord = getProvider("discord");
48+
expect(discord.refreshEnabled).toBe(true);
49+
});
50+
51+
it("should have correct refresh configuration for LinkedIn", () => {
52+
const linkedin = getProvider("linkedin");
53+
expect(linkedin.refreshEnabled).toBe(true);
54+
});
55+
56+
it("should have correct refresh configuration for Reddit", () => {
57+
const reddit = getProvider("reddit");
58+
expect(reddit.refreshEnabled).toBe(true);
59+
});
60+
61+
it("should have correct refresh configuration for GitHub", () => {
62+
const github = getProvider("github");
63+
expect(github.refreshEnabled).toBe(false);
64+
});
65+
});
66+
67+
describe("Token Refresh Logic", () => {
68+
it("should determine when token needs refresh", () => {
69+
const provider = getProvider("google-mail");
70+
71+
// No expiration date - no refresh needed
72+
expect(provider.needsRefresh(undefined)).toBe(false);
73+
74+
// Token expires in 3 minutes (within 5 minute buffer) - needs refresh
75+
const soonExpiry = new Date(Date.now() + 3 * 60 * 1000);
76+
expect(provider.needsRefresh(soonExpiry)).toBe(true);
77+
78+
// Token expires in 10 minutes (beyond 5 minute buffer) - no refresh
79+
const laterExpiry = new Date(Date.now() + 10 * 60 * 1000);
80+
expect(provider.needsRefresh(laterExpiry)).toBe(false);
81+
82+
// Token already expired - needs refresh
83+
const pastExpiry = new Date(Date.now() - 1 * 60 * 1000);
84+
expect(provider.needsRefresh(pastExpiry)).toBe(true);
85+
});
86+
87+
it("should respect custom buffer period", () => {
88+
const provider = getProvider("google-mail");
89+
90+
// Token expires in 8 minutes
91+
const expiresAt = new Date(Date.now() + 8 * 60 * 1000);
92+
93+
// With default 5 minute buffer - no refresh
94+
expect(provider.needsRefresh(expiresAt)).toBe(false);
95+
96+
// With 10 minute buffer - needs refresh
97+
expect(provider.needsRefresh(expiresAt, 10)).toBe(true);
98+
});
99+
100+
it("should throw error when refreshing GitHub tokens", async () => {
101+
const github = getProvider("github");
102+
103+
await expect(
104+
github.refreshToken("test-token", "client-id", "client-secret")
105+
).rejects.toThrow("doesn't support token refresh");
106+
});
107+
});
108+
109+
describe("OAuthError", () => {
110+
it("should create error with redirect error code", () => {
111+
const error = new OAuthError("oauth_failed", "Authentication failed");
112+
expect(error.message).toBe("Authentication failed");
113+
expect(error.redirectError).toBe("oauth_failed");
114+
expect(error.name).toBe("OAuthError");
115+
});
116+
117+
it("should be instance of Error", () => {
118+
const error = new OAuthError("oauth_failed", "Authentication failed");
119+
expect(error instanceof Error).toBe(true);
120+
});
121+
});
122+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { OAuthProvider } from "../OAuthProvider";
2+
import type { DiscordToken, DiscordUser } from "../types";
3+
4+
export class DiscordProvider extends OAuthProvider<DiscordToken, DiscordUser> {
5+
readonly name = "discord";
6+
readonly displayName = "Discord";
7+
readonly authorizationEndpoint = "https://discord.com/oauth2/authorize";
8+
readonly tokenEndpoint = "https://discord.com/api/oauth2/token";
9+
readonly userInfoEndpoint = "https://discord.com/api/users/@me";
10+
readonly scopes = ["identify", "email", "guilds"];
11+
12+
// Token refresh configuration
13+
readonly refreshEnabled = true;
14+
readonly refreshEndpoint = "https://discord.com/api/oauth2/token";
15+
16+
// Required implementations
17+
protected formatIntegrationName(user: DiscordUser): string {
18+
return `Discord - ${user.username || user.global_name || "User"}`;
19+
}
20+
21+
protected formatUserMetadata(user: DiscordUser): Record<string, any> {
22+
return {
23+
username: user.username,
24+
globalName: user.global_name,
25+
discriminator: user.discriminator,
26+
avatar: user.avatar,
27+
userId: user.id,
28+
};
29+
}
30+
31+
protected extractAccessToken(token: DiscordToken): string {
32+
return token.access_token;
33+
}
34+
35+
protected extractRefreshToken(token: DiscordToken): string {
36+
return token.refresh_token;
37+
}
38+
39+
protected extractExpiresAt(token: DiscordToken): Date {
40+
return new Date(Date.now() + token.expires_in * 1000);
41+
}
42+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { OAuthProvider } from "../OAuthProvider";
2+
import type { GitHubToken, GitHubUser } from "../types";
3+
4+
export class GitHubProvider extends OAuthProvider<GitHubToken, GitHubUser> {
5+
readonly name = "github";
6+
readonly displayName = "GitHub";
7+
readonly authorizationEndpoint = "https://github.com/login/oauth/authorize";
8+
readonly tokenEndpoint = "https://github.com/login/oauth/access_token";
9+
readonly userInfoEndpoint = "https://api.github.com/user";
10+
readonly scopes = ["user", "repo", "read:org"];
11+
12+
// GitHub tokens don't expire, no refresh needed
13+
readonly refreshEnabled = false;
14+
15+
// GitHub expects JSON response type header
16+
protected getTokenHeaders(): Record<string, string> {
17+
return {
18+
Accept: "application/json",
19+
"Content-Type": "application/json",
20+
};
21+
}
22+
23+
// GitHub uses JSON body instead of URLSearchParams
24+
protected buildTokenRequestBody(
25+
code: string,
26+
clientId: string,
27+
clientSecret: string,
28+
redirectUri: string
29+
): any {
30+
return JSON.stringify({
31+
client_id: clientId,
32+
client_secret: clientSecret,
33+
code,
34+
redirect_uri: redirectUri,
35+
});
36+
}
37+
38+
// GitHub user info needs special headers
39+
protected getUserInfoHeaders(accessToken: string): Record<string, string> {
40+
return {
41+
Authorization: `Bearer ${accessToken}`,
42+
Accept: "application/vnd.github.v3+json",
43+
"User-Agent": "Dafthunk/1.0",
44+
};
45+
}
46+
47+
// Required implementations
48+
protected formatIntegrationName(user: GitHubUser): string {
49+
return `GitHub - ${user.login || user.name}`;
50+
}
51+
52+
protected formatUserMetadata(user: GitHubUser): Record<string, any> {
53+
return {
54+
userId: user.id,
55+
login: user.login,
56+
name: user.name,
57+
email: user.email,
58+
avatarUrl: user.avatar_url,
59+
};
60+
}
61+
62+
protected extractAccessToken(token: GitHubToken): string {
63+
return token.access_token;
64+
}
65+
66+
protected extractRefreshToken(_token: GitHubToken): undefined {
67+
return undefined; // GitHub tokens don't have refresh tokens
68+
}
69+
70+
protected extractExpiresAt(_token: GitHubToken): undefined {
71+
return undefined; // GitHub tokens don't expire
72+
}
73+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { OAuthProvider } from "../OAuthProvider";
2+
import type { GoogleToken, GoogleUser } from "../types";
3+
4+
export class GoogleCalendarProvider extends OAuthProvider<
5+
GoogleToken,
6+
GoogleUser
7+
> {
8+
readonly name = "google-calendar";
9+
readonly displayName = "Google Calendar";
10+
readonly authorizationEndpoint =
11+
"https://accounts.google.com/o/oauth2/v2/auth";
12+
readonly tokenEndpoint = "https://oauth2.googleapis.com/token";
13+
readonly userInfoEndpoint = "https://www.googleapis.com/oauth2/v2/userinfo";
14+
readonly scopes = [
15+
"openid",
16+
"email",
17+
"profile",
18+
"https://www.googleapis.com/auth/calendar",
19+
];
20+
21+
// Token refresh configuration
22+
readonly refreshEnabled = true;
23+
readonly refreshEndpoint = "https://oauth2.googleapis.com/token";
24+
25+
// Google-specific authorization parameters
26+
protected customizeAuthUrl(url: URL): void {
27+
url.searchParams.set("access_type", "offline");
28+
url.searchParams.set("prompt", "consent");
29+
}
30+
31+
// Required implementations
32+
protected formatIntegrationName(user: GoogleUser): string {
33+
return `Google Calendar - ${user.email || user.name}`;
34+
}
35+
36+
protected formatUserMetadata(user: GoogleUser): Record<string, any> {
37+
return {
38+
email: user.email,
39+
name: user.name,
40+
picture: user.picture,
41+
};
42+
}
43+
44+
protected extractAccessToken(token: GoogleToken): string {
45+
return token.access_token;
46+
}
47+
48+
protected extractRefreshToken(token: GoogleToken): string | undefined {
49+
return token.refresh_token;
50+
}
51+
52+
protected extractExpiresAt(token: GoogleToken): Date {
53+
return new Date(Date.now() + token.expires_in * 1000);
54+
}
55+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { OAuthProvider } from "../OAuthProvider";
2+
import type { GoogleToken, GoogleUser } from "../types";
3+
4+
export class GoogleMailProvider extends OAuthProvider<GoogleToken, GoogleUser> {
5+
readonly name = "google-mail";
6+
readonly displayName = "Google Mail";
7+
readonly authorizationEndpoint =
8+
"https://accounts.google.com/o/oauth2/v2/auth";
9+
readonly tokenEndpoint = "https://oauth2.googleapis.com/token";
10+
readonly userInfoEndpoint = "https://www.googleapis.com/oauth2/v2/userinfo";
11+
readonly scopes = [
12+
"openid",
13+
"email",
14+
"profile",
15+
"https://www.googleapis.com/auth/gmail.modify",
16+
];
17+
18+
// Token refresh configuration
19+
readonly refreshEnabled = true;
20+
readonly refreshEndpoint = "https://oauth2.googleapis.com/token";
21+
22+
// Google-specific authorization parameters
23+
protected customizeAuthUrl(url: URL): void {
24+
url.searchParams.set("access_type", "offline");
25+
url.searchParams.set("prompt", "consent");
26+
}
27+
28+
// Required implementations
29+
protected formatIntegrationName(user: GoogleUser): string {
30+
return `Google Mail - ${user.email || user.name}`;
31+
}
32+
33+
protected formatUserMetadata(user: GoogleUser): Record<string, any> {
34+
return {
35+
email: user.email,
36+
name: user.name,
37+
picture: user.picture,
38+
};
39+
}
40+
41+
protected extractAccessToken(token: GoogleToken): string {
42+
return token.access_token;
43+
}
44+
45+
protected extractRefreshToken(token: GoogleToken): string | undefined {
46+
return token.refresh_token;
47+
}
48+
49+
protected extractExpiresAt(token: GoogleToken): Date {
50+
return new Date(Date.now() + token.expires_in * 1000);
51+
}
52+
}

0 commit comments

Comments
 (0)