Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat #15: Add google oauth #27

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ NUXT_OAUTH_TWITCH_CLIENT_ID=
NUXT_OAUTH_TWITCH_CLIENT_SECRET=
NUXT_OAUTH_TWITCH_REDIRECT_URL=http://localhost

NUXT_OAUTH_GOOGLE_CLIENT_ID=
NUXT_OAUTH_GOOGLE_CLIENT_SECRET=

NUXT_SESSION_PASSWORD=
1 change: 1 addition & 0 deletions app/components/Auth/Form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ defineProps<{
:providers="[
{ label: 'GitHub', icon: 'i-simple-icons-github', color: 'gray', external: true, to: '/auth/github' },
{ label: 'Twitch', icon: 'i-simple-icons-twitch', color: 'gray', external: true, to: '/auth/twitch' },
{ label: 'Google', icon: 'i-simple-icons-google', color: 'gray', external: true, to: '/auth/google' },
]"
>
<template #footer>
Expand Down
14 changes: 12 additions & 2 deletions app/components/Profile/ProfileSectionProviders.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ const { user } = useUserSession()

const isTwitchConnected = computed(() => Boolean(user.value?.twitchId))
const isGithubConnected = computed(() => Boolean(user.value?.githubId))

const isGoogleConnected = computed(() => Boolean(user.value?.googleId))
const { $csrfFetch } = useNuxtApp()
const { fetch: fetchUserSession } = useUserSession()

async function disconnect(providerName: 'github' | 'twitch') {
async function disconnect(providerName: 'github' | 'twitch' | 'google') {
try {
await $csrfFetch(`/api/me/providers/${providerName}`, {
method: 'DELETE',
Expand Down Expand Up @@ -55,6 +55,16 @@ async function disconnect(providerName: 'github' | 'twitch') {
>
{{ user?.twitchId ? 'Remove connection' : 'Connect Twitch' }}
</UButton>

<UButton
color="gray"
:to="isGoogleConnected ? undefined : '/auth/google'"
external
icon="i-simple-icons-google"
@click="isGoogleConnected ? disconnect('google') : undefined"
>
{{ user?.googleId ? 'Remove connection' : 'Connect Google' }}
</UButton>
</div>
</ucard>
</ProfileSection>
Expand Down
2 changes: 2 additions & 0 deletions auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ declare module '#auth-utils' {
avatar: string | null
githubId?: number | null
twitchId?: string | null
googleId?: string | null
googleToken?: string | null
verifiedAt: string | null
emailToVerify?: string | null
}
Expand Down
2 changes: 1 addition & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default defineNuxtConfig({
},
headers: {
contentSecurityPolicy: {
'img-src': ['\'self\'', 'data:', 'https://avatars.githubusercontent.com', 'https://static-cdn.jtvnw.net/'],
'img-src': ['\'self\'', 'data:', 'https://avatars.githubusercontent.com', 'https://static-cdn.jtvnw.net', 'https://lh3.googleusercontent.com'],
'script-src': ['\'self\'', 'https', '\'nonce-{{nonce}}\'', 'https://static.cloudflareinsights.com'],
},
crossOriginEmbedderPolicy: isProd ? 'credentialless' : false,
Expand Down
12 changes: 11 additions & 1 deletion server/api/me/providers/[providerName].delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default defineEventHandler(async (event) => {
const providerName = getRouterParam(event, 'providerName')

// A user must have at least one provider linked
const providers = (['twitch', 'github'] as const).filter(provider => user[`${provider}Id`])
const providers = (['twitch', 'github', 'google'] as const).filter(provider => user[`${provider}Id`])

if (providers.length === 1) {
throw createError({
Expand Down Expand Up @@ -33,6 +33,16 @@ export default defineEventHandler(async (event) => {
githubId: null,
})
}
else if (providerName === 'google') {
await updateUser(user.id, {
googleId: null,
googleToken: null,
})
await updateUserSession(event, {
...user,
googleId: null,
})
}
else {
throw createError({
statusCode: 400,
Expand Down
3 changes: 3 additions & 0 deletions server/database/migrations/0001_illegal_talos.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE `users` ADD `google_id` text;--> statement-breakpoint
ALTER TABLE `users` ADD `google_token` text;--> statement-breakpoint
CREATE UNIQUE INDEX `users_google_id_unique` ON `users` (`google_id`);
153 changes: 153 additions & 0 deletions server/database/migrations/meta/0001_snapshot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
{
"version": "6",
"dialect": "sqlite",
"id": "019e873c-a77c-4a0c-8738-5275cad42d63",
"prevId": "e9773c29-81a1-4979-847b-e4c5982902bc",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_to_verify": {
"name": "email_to_verify",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_id": {
"name": "github_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_token": {
"name": "github_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"twitch_id": {
"name": "twitch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"twitch_token": {
"name": "twitch_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"google_id": {
"name": "google_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"google_token": {
"name": "google_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"verified_at": {
"name": "verified_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_github_id_unique": {
"name": "users_github_id_unique",
"columns": [
"github_id"
],
"isUnique": true
},
"users_twitch_id_unique": {
"name": "users_twitch_id_unique",
"columns": [
"twitch_id"
],
"isUnique": true
},
"users_google_id_unique": {
"name": "users_google_id_unique",
"columns": [
"google_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
7 changes: 7 additions & 0 deletions server/database/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"when": 1724098052504,
"tag": "0000_grey_nova",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1728919411090,
"tag": "0001_illegal_talos",
"breakpoints": true
}
]
}
2 changes: 2 additions & 0 deletions server/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const users = sqliteTable('users', {
githubToken: text('github_token'),
twitchId: text('twitch_id').unique(),
twitchToken: text('twitch_token'),
googleId: text('google_id').unique(),
googleToken: text('google_token'),
verifiedAt: text('verified_at'),
createdAt: text('created_at')
.notNull()
Expand Down
76 changes: 76 additions & 0 deletions server/routes/auth/google.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export default oauthGoogleEventHandler({
config: {
// Example:
scope: ['email', 'openid', 'profile'],
},
async onSuccess(event, { user: googleUser, tokens }) {
// Fetch the current session if the user is already signed in
const { user: userSession } = await getUserSession(event)

// If the user is signed in, link the Google account to the existing user
if (userSession?.id) {
const existingUser = await findUserById(userSession.id)
if (existingUser) {
await updateUser(userSession.id, {
googleId: googleUser.sub,
googleToken: tokens.access_token,
})
await updateUserSession(event, { ...userSession, googleId: googleUser.sub })
return sendRedirect(event, '/profile')
}
}

// Check if there's an existing user with this Google ID or email
let user = await findUserByGoogleId(googleUser.sub)
if (user) {
await updateUser(user.id, {
googleId: googleUser.sub,
googleToken: tokens.access_token,
})
await updateUserSession(event, {
...user,
googleId: googleUser.sub,
})
return sendRedirect(event, '/profile')
}

user = await findUserBy(
and(
eq(tables.users.email, googleUser.email),
isNull(tables.users.googleId),
),
)
if (user) {
await updateSession(event,
{
password: useRuntimeConfig(event).session.password,
},
{
message: 'An existing account for this email already exists. Please login and visit your profile settings to add support for Google authentication.',
})
return sendRedirect(event, '/login')
}

// If the user is not signed in and no user exists with that Google ID, create a new user
const createdUser = await createUser({
name: (googleUser.given_name || 'no name') as string,
email: googleUser.email as string,
avatar: googleUser.picture as string,
googleId: googleUser.sub as string,
googleToken: tokens.access_token as string,
verifiedAt: new Date().toUTCString(),
})

await updateUserSession(event, {
id: createdUser.id,
name: createdUser.name,
email: createdUser.email,
avatar: createdUser.avatar,
verifiedAt: createdUser.verifiedAt,
googleId: googleUser.sub,
})
return sendRedirect(event, '/profile')
},

},
)
4 changes: 4 additions & 0 deletions server/utils/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export async function findUserByTwitchId(twitchId: string) {
.get()
}

export async function findUserByGoogleId(googleId: string) {
return useDrizzle().select().from(tables.users).where(eq(tables.users.googleId, googleId)).get()
}

export async function findUserBy(query: SQL | undefined) {
return useDrizzle().select().from(tables.users).where(query).get()
}
Expand Down
Loading