feat:google oauth

This commit is contained in:
2025-10-03 17:00:21 +02:00
parent 6fddf426b6
commit 0caa5dc9d6
12 changed files with 292 additions and 1 deletions

View File

@@ -25,3 +25,7 @@ POSTGRES_PRISMA_URL=****************************
NEXT_PUBLIC_STACK_PROJECT_ID=**************************** NEXT_PUBLIC_STACK_PROJECT_ID=****************************
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=**************************************** NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=****************************************
STACK_SECRET_SERVER_KEY=*********************** STACK_SECRET_SERVER_KEY=***********************
# Google Oauth for google login
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""

View File

@@ -0,0 +1,3 @@
ALTER TABLE "user" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "google_id" text;--> statement-breakpoint
ALTER TABLE "user" ADD CONSTRAINT "user_google_id_unique" UNIQUE("google_id");

View File

@@ -0,0 +1,122 @@
{
"id": "277e5a0d-b5f7-40e3-aaf9-c6cd5fe3d0a0",
"prevId": "d190d67d-4be7-486a-b23e-9106feca588a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"google_id": {
"name": "google_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1758905260578, "when": 1758905260578,
"tag": "0001_complete_namora", "tag": "0001_complete_namora",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1759502119139,
"tag": "0002_robust_firedrake",
"breakpoints": true
} }
] ]
} }

View File

@@ -56,6 +56,7 @@
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@sveltejs/adapter-vercel": "^5.10.2", "@sveltejs/adapter-vercel": "^5.10.2",
"arctic": "^3.7.0",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"svelte-maplibre": "^1.2.1" "svelte-maplibre": "^1.2.1"
}, },

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/button/index.js'; import { Button } from '$lib/components/button/index.js';
import * as Card from '$lib/components/card/index.js'; import * as Card from '$lib/components/card/index.js';
import { Label } from '$lib/components/label/index.js'; import { Label } from '$lib/components/label/index.js';
@@ -61,6 +62,15 @@
</Button> </Button>
</div> </div>
</div> </div>
<Button variant="outline" class="mt-4 w-full" onclick={() => goto('/login/google')}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
Login with Google
</Button>
</form> </form>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -82,3 +82,26 @@ export function deleteSessionTokenCookie(event: RequestEvent) {
path: '/' path: '/'
}); });
} }
export async function getUserFromGoogleId(googleId: string) {
const [user] = await db.select().from(table.user).where(eq(table.user.googleId, googleId));
return user || null;
}
export async function createUser(googleId: string, name: string) {
const userId = generateUserId();
const user: table.User = {
id: userId,
username: name,
googleId,
passwordHash: null,
age: null
};
await db.insert(table.user).values(user);
return user;
}
function generateUserId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}

View File

@@ -4,7 +4,8 @@ export const user = pgTable('user', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
age: integer('age'), age: integer('age'),
username: text('username').notNull().unique(), username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull() passwordHash: text('password_hash'),
googleId: text('google_id').unique()
}); });
export const session = pgTable('session', { export const session = pgTable('session', {

8
src/lib/server/oauth.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Google } from 'arctic';
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } from '$env/static/private';
export const google = new Google(
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
'https://sergeno.ziasvannes.tech/login/google/callback'
);

View File

@@ -36,6 +36,10 @@ export const actions: Actions = {
return fail(400, { message: 'Incorrect username or password' }); return fail(400, { message: 'Incorrect username or password' });
} }
if (!existingUser.passwordHash) {
return fail(400, { message: 'Please sign in with Google for this account' });
}
const validPassword = await verify(existingUser.passwordHash, password, { const validPassword = await verify(existingUser.passwordHash, password, {
memoryCost: 19456, memoryCost: 19456,
timeCost: 2, timeCost: 2,

View File

@@ -0,0 +1,30 @@
import { generateState, generateCodeVerifier } from 'arctic';
import { google } from '$lib/server/oauth';
import type { RequestEvent } from '@sveltejs/kit';
export async function GET(event: RequestEvent): Promise<Response> {
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = google.createAuthorizationURL(state, codeVerifier, ['openid', 'profile']);
event.cookies.set('google_oauth_state', state, {
path: '/',
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax'
});
event.cookies.set('google_code_verifier', codeVerifier, {
path: '/',
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax'
});
return new Response(null, {
status: 302,
headers: {
Location: url.toString()
}
});
}

View File

@@ -0,0 +1,78 @@
import {
generateSessionToken,
createSession,
setSessionTokenCookie,
getUserFromGoogleId,
createUser
} from '$lib/server/auth';
import { google } from '$lib/server/oauth';
import { decodeIdToken } from 'arctic';
import type { RequestEvent } from '@sveltejs/kit';
import type { OAuth2Tokens } from 'arctic';
export async function GET(event: RequestEvent): Promise<Response> {
const code = event.url.searchParams.get('code');
const state = event.url.searchParams.get('state');
const storedState = event.cookies.get('google_oauth_state') ?? null;
const codeVerifier = event.cookies.get('google_code_verifier') ?? null;
if (code === null || state === null || storedState === null || codeVerifier === null) {
return new Response(null, {
status: 400
});
}
if (state !== storedState) {
return new Response(null, {
status: 400
});
}
let tokens: OAuth2Tokens;
try {
tokens = await google.validateAuthorizationCode(code, codeVerifier);
} catch {
// Invalid code or client credentials
return new Response(null, {
status: 400
});
}
const claims = decodeIdToken(tokens.idToken()) as { sub?: string; name?: string };
const googleUserId = claims.sub;
const username = claims.name;
if (!googleUserId || !username) {
return new Response(null, {
status: 400
});
}
const existingUser = await getUserFromGoogleId(googleUserId);
if (existingUser !== null) {
const sessionToken = generateSessionToken();
const session = await createSession(sessionToken, existingUser.id);
setSessionTokenCookie(event, sessionToken, session.expiresAt);
return new Response(null, {
status: 302,
headers: {
Location: '/'
}
});
}
const user = await createUser(googleUserId, username);
const sessionToken = generateSessionToken();
const session = await createSession(sessionToken, user.id);
setSessionTokenCookie(event, sessionToken, session.expiresAt);
return new Response(null, {
status: 302,
headers: {
Location: '/'
}
});
}