Skip to content

Commit 2cc535a

Browse files
aster-voidclaude
andcommitted
treewide: add Google OAuth login and 401 redirect
- Add Google OAuth authentication using arctic library - Redirect to /signin on 401 API responses - Update signin page to use Google button 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 7725826 commit 2cc535a

File tree

10 files changed

+196
-47
lines changed

10 files changed

+196
-47
lines changed

.env.sample

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ PUBLIC_API_BASE_URL=http://localhost:3000
66
DATABASE_URL=postgres://localhost:5432/prism
77
# JWT
88
JWT_SECRET=your-secret-key
9+
# Google OAuth
10+
GOOGLE_CLIENT_ID=your-google-client-id
11+
GOOGLE_CLIENT_SECRET=your-google-client-secret

apps/api-client/src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,21 @@ export type * from "./types.ts";
1111

1212
export interface ApiConfig {
1313
baseUrl: string;
14-
fetch?: typeof fetch;
14+
onUnauthorized?: () => void;
1515
}
1616

1717
/**
1818
* Creates an API client instance using Eden Treaty.
1919
* The client provides type-safe access to all API endpoints.
2020
*/
2121
export function createApiClient(config: ApiConfig) {
22-
return treaty<App>(config.baseUrl);
22+
return treaty<App>(config.baseUrl, {
23+
onResponse: (response) => {
24+
if (response.status === 401 && config.onUnauthorized) {
25+
config.onUnauthorized();
26+
}
27+
},
28+
});
2329
}
2430

2531
export type ApiClient = ReturnType<typeof createApiClient>;

apps/desktop/src/components/channels/ChannelList.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
return unwrapResponse(response);
2626
});
2727
28-
const unreadManager = new UnreadManager(api, organizationId);
28+
const unreadManager = $derived(new UnreadManager(api, organizationId));
2929
let showUserSearch = $state(false);
3030
3131
onMount(() => {

apps/desktop/src/lib/api.svelte.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getTask,
99
getVote,
1010
} from "@apps/api-client";
11+
import { goto } from "$app/navigation";
1112

1213
// Re-export helper functions
1314
export { getChannel, getFile, getMessage, getOrganization, getTask, getVote };
@@ -30,7 +31,10 @@ export function setupApi(baseUrl?: string) {
3031
"API base URL is required. Provide baseUrl or set PUBLIC_API_BASE_URL environment variable",
3132
);
3233
}
33-
apiClient = createApiClient({ baseUrl: url });
34+
apiClient = createApiClient({
35+
baseUrl: url,
36+
onUnauthorized: () => goto("/signin", { replaceState: true }),
37+
});
3438
return apiClient;
3539
}
3640

Lines changed: 11 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,33 @@
11
<script lang="ts">
22
import { useAuth } from "@/lib/auth.svelte.ts";
33
import { goto } from "$app/navigation";
4+
import GoogleButton from "./GoogleButton.svelte";
45
56
const auth = useAuth();
67
const isAuthenticated = $derived(auth.isAuthenticated);
78
const isLoading = $derived(auth.isLoading);
89
9-
let email = $state("");
10-
let submitting = $state(false);
11-
1210
$effect(() => {
1311
if (isAuthenticated) {
1412
goto("/", { replaceState: true });
1513
}
1614
});
1715
18-
async function handleSubmit(event: Event) {
19-
event.preventDefault();
20-
submitting = true;
21-
try {
22-
await auth.signIn(email);
23-
goto("/", { replaceState: true });
24-
} catch {
25-
alert("Sign in failed");
26-
} finally {
27-
submitting = false;
28-
}
16+
function handleGoogleSignIn() {
17+
const apiUrl = import.meta.env.PUBLIC_API_BASE_URL;
18+
window.location.href = `${apiUrl}/auth/google/authorize`;
2919
}
3020
</script>
3121

3222
<div class="hero bg-base-200 min-h-screen">
3323
<div class="card bg-base-100 w-full max-w-sm shrink-0 shadow-2xl">
34-
<form class="card-body" onsubmit={handleSubmit}>
24+
<div class="card-body">
3525
<h1 class="text-2xl font-bold">Sign In to Prism</h1>
36-
37-
<div class="form-control">
38-
<label class="label" for="email">
39-
<span class="label-text">Email</span>
40-
</label>
41-
<input
42-
id="email"
43-
type="email"
44-
placeholder="[email protected]"
45-
class="input input-bordered"
46-
bind:value={email}
47-
required
48-
/>
49-
</div>
50-
51-
<div class="form-control mt-6">
52-
<button
53-
type="submit"
54-
class="btn btn-primary"
55-
disabled={isLoading || submitting}
56-
>
57-
{#if isLoading || submitting}
58-
<span class="loading loading-spinner"></span>
59-
{/if}
60-
Sign In
61-
</button>
62-
</div>
63-
</form>
26+
{#if isLoading}
27+
<span class="loading loading-spinner mx-auto"></span>
28+
{:else}
29+
<GoogleButton onclick={handleGoogleSignIn} />
30+
{/if}
31+
</div>
6432
</div>
6533
</div>

apps/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@elysiajs/cors": "^1.4.0",
1616
"@elysiajs/jwt": "^1.4.0",
1717
"@sinclair/typebox": "^0.34.41",
18+
"arctic": "^3.7.0",
1819
"drizzle-orm": "^0.45.0",
1920
"elysia": "^1.4.18",
2021
"postgres": "^3.4.7",
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import {
2+
decodeIdToken,
3+
Google,
4+
generateCodeVerifier,
5+
generateState,
6+
} from "arctic";
7+
import { and, eq } from "drizzle-orm";
8+
import { Elysia, t } from "elysia";
9+
import { db } from "../../db/index.ts";
10+
import { accounts, users } from "../../db/schema.ts";
11+
import { env } from "../../env.ts";
12+
import { authMiddleware } from "../../middleware/auth.ts";
13+
14+
const google = new Google(
15+
env.GOOGLE_CLIENT_ID,
16+
env.GOOGLE_CLIENT_SECRET,
17+
`${env.CORS_ORIGIN}/auth/google/callback`,
18+
);
19+
20+
export const googleAuthRoutes = new Elysia({ prefix: "/auth/google" })
21+
.use(authMiddleware)
22+
.get("/authorize", async ({ cookie, redirect }) => {
23+
const state = generateState();
24+
const codeVerifier = generateCodeVerifier();
25+
const url = google.createAuthorizationURL(state, codeVerifier, [
26+
"openid",
27+
"email",
28+
"profile",
29+
]);
30+
31+
cookie.google_oauth_state?.set({
32+
value: state,
33+
httpOnly: true,
34+
maxAge: 60 * 10,
35+
path: "/",
36+
});
37+
cookie.google_code_verifier?.set({
38+
value: codeVerifier,
39+
httpOnly: true,
40+
maxAge: 60 * 10,
41+
path: "/",
42+
});
43+
44+
return redirect(url.toString());
45+
})
46+
.get(
47+
"/callback",
48+
async ({ query, cookie, jwt, redirect }) => {
49+
const { code, state } = query;
50+
const storedState = cookie.google_oauth_state?.value;
51+
const codeVerifier = cookie.google_code_verifier?.value;
52+
53+
if (state !== storedState || typeof codeVerifier !== "string") {
54+
return redirect(`${env.CORS_ORIGIN}/signin?error=invalid_state`);
55+
}
56+
57+
const tokens = await google.validateAuthorizationCode(code, codeVerifier);
58+
const idToken = tokens.idToken();
59+
const claims = decodeIdToken(idToken) as {
60+
sub: string;
61+
email: string;
62+
name?: string;
63+
picture?: string;
64+
};
65+
66+
// Find existing account
67+
const [existingAccount] = await db
68+
.select()
69+
.from(accounts)
70+
.where(
71+
and(
72+
eq(accounts.provider, "google"),
73+
eq(accounts.providerAccountId, claims.sub),
74+
),
75+
);
76+
77+
let user: typeof users.$inferSelect | undefined;
78+
if (existingAccount) {
79+
[user] = await db
80+
.select()
81+
.from(users)
82+
.where(eq(users.id, existingAccount.userId));
83+
} else {
84+
// Check if user with email exists
85+
[user] = await db
86+
.select()
87+
.from(users)
88+
.where(eq(users.email, claims.email));
89+
90+
if (!user) {
91+
[user] = await db
92+
.insert(users)
93+
.values({
94+
email: claims.email,
95+
name: claims.name ?? claims.email.split("@")[0],
96+
image: claims.picture,
97+
emailVerified: new Date(),
98+
})
99+
.returning();
100+
}
101+
102+
if (!user) {
103+
return redirect(
104+
`${env.CORS_ORIGIN}/signin?error=user_creation_failed`,
105+
);
106+
}
107+
108+
// Link account
109+
await db.insert(accounts).values({
110+
userId: user.id,
111+
type: "oauth",
112+
provider: "google",
113+
providerAccountId: claims.sub,
114+
accessToken: tokens.accessToken(),
115+
idToken: idToken,
116+
});
117+
}
118+
119+
if (!user) {
120+
return redirect(`${env.CORS_ORIGIN}/signin?error=user_not_found`);
121+
}
122+
123+
const token = await jwt.sign({
124+
id: user.id,
125+
email: user.email,
126+
name: user.name,
127+
});
128+
129+
cookie.token?.set({
130+
value: token,
131+
httpOnly: true,
132+
maxAge: 7 * 24 * 60 * 60,
133+
path: "/",
134+
});
135+
136+
// Clear OAuth cookies
137+
cookie.google_oauth_state?.set({ value: "", maxAge: 0, path: "/" });
138+
cookie.google_code_verifier?.set({ value: "", maxAge: 0, path: "/" });
139+
140+
return redirect(env.CORS_ORIGIN);
141+
},
142+
{
143+
query: t.Object({
144+
code: t.String(),
145+
state: t.String(),
146+
}),
147+
},
148+
);

apps/server/src/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ const envSchema = v.object({
55
JWT_SECRET: v.pipe(v.string(), v.minLength(1)),
66
CORS_ORIGIN: v.pipe(v.string(), v.url()),
77
PORT: v.optional(v.pipe(v.string(), v.transform(Number)), "3000"),
8+
GOOGLE_CLIENT_ID: v.pipe(v.string(), v.minLength(1)),
9+
GOOGLE_CLIENT_SECRET: v.pipe(v.string(), v.minLength(1)),
810
});
911

1012
const result = v.safeParse(envSchema, process.env);

apps/server/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { cors } from "@elysiajs/cors";
22
import { Elysia } from "elysia";
3+
import { googleAuthRoutes } from "./domains/auth/google.ts";
34
import { authRoutes } from "./domains/auth/routes.ts";
45
import { channelRoutes } from "./domains/channels/routes.ts";
56
import { dmRoutes } from "./domains/dms/routes.ts";
@@ -19,6 +20,7 @@ const app = new Elysia()
1920
.get("/", () => ({ message: "Prism API Server" }))
2021
.get("/health", () => ({ status: "ok", timestamp: Date.now() }))
2122
.use(authRoutes)
23+
.use(googleAuthRoutes)
2224
.use(organizationRoutes)
2325
.use(channelRoutes)
2426
.use(dmRoutes)

bun.lock

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"@elysiajs/cors": "^1.4.0",
7878
"@elysiajs/jwt": "^1.4.0",
7979
"@sinclair/typebox": "^0.34.41",
80+
"arctic": "^3.7.0",
8081
"drizzle-orm": "^0.45.0",
8182
"elysia": "^1.4.18",
8283
"postgres": "^3.4.7",
@@ -326,6 +327,16 @@
326327

327328
"@marijn/find-cluster-break": ["@marijn/[email protected]", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
328329

330+
"@oslojs/asn1": ["@oslojs/[email protected]", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
331+
332+
"@oslojs/binary": ["@oslojs/[email protected]", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
333+
334+
"@oslojs/crypto": ["@oslojs/[email protected]", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
335+
336+
"@oslojs/encoding": ["@oslojs/[email protected]", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
337+
338+
"@oslojs/jwt": ["@oslojs/[email protected]", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
339+
329340
"@panva/hkdf": ["@panva/[email protected]", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
330341

331342
"@pinojs/redact": ["@pinojs/[email protected]", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
@@ -486,6 +497,8 @@
486497

487498
"ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
488499

500+
"arctic": ["[email protected]", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw=="],
501+
489502
"argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
490503

491504
"aria-query": ["[email protected]", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
@@ -944,6 +957,8 @@
944957

945958
"@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
946959

960+
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/[email protected]", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
961+
947962
"@poppinss/dumper/supports-color": ["[email protected]", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
948963

949964
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],

0 commit comments

Comments
 (0)