Skip to content

Commit e830872

Browse files
committed
feat: implement google authentication
1 parent b9d061c commit e830872

File tree

13 files changed

+18339
-2956
lines changed

13 files changed

+18339
-2956
lines changed

package-lock.json

Lines changed: 15733 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"bcrypt": "^6.0.0",
9393
"citty": "^0.1.6",
9494
"defu": "^6.1.4",
95+
"googleapis": "^162.0.0",
9596
"nodemailer": "^7.0.5"
9697
},
9798
"optionalDependencies": {

src/cli/add-google-oauth-fields.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { defineCommand } from 'citty'
2+
import { useDb } from '../runtime/server/utils/db'
3+
import { loadOptions } from './utils'
4+
5+
export default defineCommand({
6+
meta: {
7+
name: 'add-google-oauth-fields',
8+
description: 'Add Google OAuth fields (google_id, profile_picture) to existing users table'
9+
},
10+
async run() {
11+
console.log('[Nuxt Users] Adding Google OAuth fields to users table...')
12+
13+
const options = await loadOptions()
14+
const connectorName = options.connector!.name
15+
const db = await useDb(options)
16+
const tableName = options.tables.users
17+
18+
console.log(`[Nuxt Users] DB:Migrate ${connectorName} Users Table Adding Google OAuth fields...`)
19+
20+
try {
21+
if (connectorName === 'sqlite') {
22+
// Check if columns already exist
23+
const tableInfo = await db.sql`PRAGMA table_info(${tableName})` as { rows: Array<{ name: string }> }
24+
const columnNames = tableInfo.rows.map(row => row.name)
25+
26+
if (!columnNames.includes('google_id')) {
27+
await db.sql`ALTER TABLE {${tableName}} ADD COLUMN google_id TEXT UNIQUE`
28+
console.log('[Nuxt Users] Added google_id column to SQLite users table ✅')
29+
}
30+
31+
if (!columnNames.includes('profile_picture')) {
32+
await db.sql`ALTER TABLE {${tableName}} ADD COLUMN profile_picture TEXT`
33+
console.log('[Nuxt Users] Added profile_picture column to SQLite users table ✅')
34+
}
35+
}
36+
37+
if (connectorName === 'mysql') {
38+
// Check if columns exist
39+
const checkColumns = await db.sql`
40+
SELECT COLUMN_NAME
41+
FROM INFORMATION_SCHEMA.COLUMNS
42+
WHERE TABLE_SCHEMA = DATABASE()
43+
AND TABLE_NAME = ${tableName}
44+
AND COLUMN_NAME IN ('google_id', 'profile_picture')
45+
` as { rows: Array<{ COLUMN_NAME: string }> }
46+
47+
const existingColumns = checkColumns.rows.map(row => row.COLUMN_NAME)
48+
49+
if (!existingColumns.includes('google_id')) {
50+
await db.sql`ALTER TABLE {${tableName}} ADD COLUMN google_id VARCHAR(255) UNIQUE`
51+
console.log('[Nuxt Users] Added google_id column to MySQL users table ✅')
52+
}
53+
54+
if (!existingColumns.includes('profile_picture')) {
55+
await db.sql`ALTER TABLE {${tableName}} ADD COLUMN profile_picture TEXT`
56+
console.log('[Nuxt Users] Added profile_picture column to MySQL users table ✅')
57+
}
58+
}
59+
60+
if (connectorName === 'postgresql') {
61+
// Check if columns exist
62+
const checkColumns = await db.sql`
63+
SELECT column_name
64+
FROM information_schema.columns
65+
WHERE table_name = ${tableName}
66+
AND column_name IN ('google_id', 'profile_picture')
67+
` as { rows: Array<{ column_name: string }> }
68+
69+
const existingColumns = checkColumns.rows.map(row => row.column_name)
70+
71+
if (!existingColumns.includes('google_id')) {
72+
await db.sql`ALTER TABLE {${tableName}} ADD COLUMN google_id VARCHAR(255) UNIQUE`
73+
console.log('[Nuxt Users] Added google_id column to PostgreSQL users table ✅')
74+
}
75+
76+
if (!existingColumns.includes('profile_picture')) {
77+
await db.sql`ALTER TABLE {${tableName}} ADD COLUMN profile_picture TEXT`
78+
console.log('[Nuxt Users] Added profile_picture column to PostgreSQL users table ✅')
79+
}
80+
}
81+
82+
console.log('[Nuxt Users] Google OAuth fields migration completed successfully! ✅')
83+
84+
} catch (error) {
85+
console.error('[Nuxt Users] DB:Migration Error:', error)
86+
process.exit(1)
87+
}
88+
}
89+
})

src/cli/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import createPersonalAccessTokensTable from './create-personal-access-tokens-tab
88
import createPasswordResetTokensTable from './create-password-reset-tokens-table'
99
import createMigrationsTable from './create-migrations-table'
1010
import addActiveToUsers from './add-active-to-users'
11+
import addGoogleOauthFields from './add-google-oauth-fields'
1112
import projectInfo from './project-info'
1213

1314
// Dynamically load version from package.json at runtime
@@ -37,6 +38,7 @@ const main = defineCommand({
3738
'create-password-reset-tokens-table': createPasswordResetTokensTable,
3839
'create-migrations-table': createMigrationsTable,
3940
'add-active-to-users': addActiveToUsers,
41+
'add-google-oauth-fields': addGoogleOauthFields,
4042
'project-info': projectInfo
4143
}
4244
})

src/module.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,19 @@ export default defineNuxtModule<RuntimeModuleOptions>({
199199
handler: resolver.resolve('./runtime/server/api/nuxt-users/confirm-email.get')
200200
})
201201

202+
// Google OAuth
203+
addServerHandler({
204+
route: `${base}/auth/google/redirect`,
205+
method: 'get',
206+
handler: resolver.resolve('./runtime/server/api/nuxt-users/auth/google/redirect.get')
207+
})
208+
209+
addServerHandler({
210+
route: `${base}/auth/google/callback`,
211+
method: 'get',
212+
handler: resolver.resolve('./runtime/server/api/nuxt-users/auth/google/callback.get')
213+
})
214+
202215
// User management
203216
addServerHandler({
204217
route: `${base}`,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from 'vue'
3+
import type { ModuleOptions } from 'nuxt-users/utils'
4+
import { useRuntimeConfig } from '#imports'
5+
6+
interface Props {
7+
/**
8+
* Custom redirect endpoint for Google OAuth
9+
* @default '/api/nuxt-users/auth/google/redirect'
10+
*/
11+
redirectEndpoint?: string
12+
/**
13+
* Button text
14+
* @default 'Continue with Google'
15+
*/
16+
buttonText?: string
17+
/**
18+
* Show Google logo in button
19+
* @default true
20+
*/
21+
showLogo?: boolean
22+
/**
23+
* Custom CSS class for button
24+
*/
25+
class?: string
26+
}
27+
28+
interface Emits {
29+
(e: 'click'): void
30+
}
31+
32+
const props = withDefaults(defineProps<Props>(), {
33+
buttonText: 'Continue with Google',
34+
showLogo: true
35+
})
36+
37+
const emit = defineEmits<Emits>()
38+
39+
const { public: { nuxtUsers } } = useRuntimeConfig()
40+
const { apiBasePath } = nuxtUsers as ModuleOptions
41+
42+
const isLoading = ref(false)
43+
44+
// Compute redirect URL
45+
const redirectEndpoint = computed(() =>
46+
props.redirectEndpoint || `${apiBasePath}/auth/google/redirect`
47+
)
48+
49+
const handleGoogleLogin = async () => {
50+
isLoading.value = true
51+
emit('click')
52+
53+
try {
54+
// Navigate to Google OAuth redirect endpoint
55+
await navigateTo(redirectEndpoint.value, { external: true })
56+
} catch (error) {
57+
console.error('[Nuxt Users] Google OAuth redirect failed:', error)
58+
} finally {
59+
isLoading.value = false
60+
}
61+
}
62+
</script>
63+
64+
<template>
65+
<button
66+
type="button"
67+
class="n-users-google-btn"
68+
:class="[{ 'loading': isLoading }, props.class]"
69+
:disabled="isLoading"
70+
@click="handleGoogleLogin"
71+
>
72+
<!-- Loading spinner -->
73+
<span
74+
v-if="isLoading"
75+
class="n-users-loading-spinner"
76+
/>
77+
78+
<!-- Google logo SVG -->
79+
<svg
80+
v-if="showLogo && !isLoading"
81+
class="n-users-google-logo"
82+
viewBox="0 0 24 24"
83+
width="18"
84+
height="18"
85+
>
86+
<path
87+
fill="#4285F4"
88+
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
89+
/>
90+
<path
91+
fill="#34A853"
92+
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
93+
/>
94+
<path
95+
fill="#FBBC05"
96+
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
97+
/>
98+
<path
99+
fill="#EA4335"
100+
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
101+
/>
102+
</svg>
103+
104+
<!-- Button text -->
105+
<span class="n-users-google-text">
106+
{{ isLoading ? 'Redirecting...' : buttonText }}
107+
</span>
108+
</button>
109+
</template>
110+
111+
<!-- CSS removed - now consolidated in nuxt-users.css -->
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { defineEventHandler, sendRedirect, createError, getQuery, setCookie } from 'h3'
2+
import type { ModuleOptions } from 'nuxt-users/utils'
3+
import { useRuntimeConfig } from '#imports'
4+
import {
5+
createGoogleOAuth2Client,
6+
getGoogleUserFromCode,
7+
findOrCreateGoogleUser,
8+
createAuthTokenForUser
9+
} from '../../../utils/google-oauth'
10+
11+
export default defineEventHandler(async (event) => {
12+
const { nuxtUsers } = useRuntimeConfig()
13+
const options = nuxtUsers as ModuleOptions
14+
const query = getQuery(event)
15+
16+
// Check if Google OAuth is configured
17+
if (!options.auth.google) {
18+
return sendRedirect(event, options.auth.google?.errorRedirect || '/login?error=oauth_not_configured')
19+
}
20+
21+
// Handle OAuth errors
22+
if (query.error) {
23+
console.error('[Nuxt Users] Google OAuth error:', query.error)
24+
const errorRedirect = options.auth.google.errorRedirect || '/login?error=oauth_failed'
25+
return sendRedirect(event, errorRedirect)
26+
}
27+
28+
// Check for authorization code
29+
if (!query.code || typeof query.code !== 'string') {
30+
console.error('[Nuxt Users] Missing authorization code in OAuth callback')
31+
const errorRedirect = options.auth.google.errorRedirect || '/login?error=oauth_failed'
32+
return sendRedirect(event, errorRedirect)
33+
}
34+
35+
try {
36+
// Construct the callback URL
37+
const baseUrl = getRequestURL(event).origin
38+
const callbackPath = options.auth.google.callbackUrl || `${options.apiBasePath}/auth/google/callback`
39+
const callbackUrl = `${baseUrl}${callbackPath}`
40+
41+
// Create OAuth2 client
42+
const oauth2Client = createGoogleOAuth2Client(options.auth.google, callbackUrl)
43+
44+
// Exchange code for user info
45+
const googleUser = await getGoogleUserFromCode(oauth2Client, query.code)
46+
47+
// Find or create user in database
48+
const user = await findOrCreateGoogleUser(googleUser, options)
49+
50+
// Check if user account is active
51+
if (!user.active) {
52+
console.warn(`[Nuxt Users] Inactive user attempted Google OAuth login: ${user.email}`)
53+
const errorRedirect = options.auth.google.errorRedirect || '/login?error=account_inactive'
54+
return sendRedirect(event, errorRedirect)
55+
}
56+
57+
// Create authentication token - default to remember me for OAuth users
58+
const token = await createAuthTokenForUser(user, options, true)
59+
60+
// Set authentication cookie
61+
const cookieOptions = {
62+
httpOnly: true,
63+
secure: process.env.NODE_ENV === 'production',
64+
sameSite: 'lax' as const,
65+
path: '/',
66+
maxAge: 60 * 60 * 24 * (options.auth.rememberMeExpiration || 30) // 30 days default
67+
}
68+
69+
setCookie(event, 'auth_token', token, cookieOptions)
70+
71+
// Update last login time
72+
const { useDb } = await import('../../../utils/db')
73+
const db = await useDb(options)
74+
await db.sql`
75+
UPDATE {${options.tables.users}}
76+
SET last_login_at = CURRENT_TIMESTAMP
77+
WHERE id = ${user.id}
78+
`
79+
80+
console.log(`[Nuxt Users] Google OAuth login successful for user: ${user.email}`)
81+
82+
// Redirect to success page
83+
const successRedirect = options.auth.google.successRedirect || '/'
84+
return sendRedirect(event, successRedirect)
85+
86+
} catch (error) {
87+
console.error('[Nuxt Users] Google OAuth callback error:', error)
88+
const errorRedirect = options.auth.google?.errorRedirect || '/login?error=oauth_failed'
89+
return sendRedirect(event, errorRedirect)
90+
}
91+
})
92+
93+
// Helper function to get request URL
94+
function getRequestURL(event: any) {
95+
const headers = event.node.req.headers
96+
const host = headers.host || headers[':authority']
97+
const protocol = headers['x-forwarded-proto'] || (event.node.req.socket?.encrypted ? 'https' : 'http')
98+
return new URL(`${protocol}://${host}`)
99+
}

0 commit comments

Comments
 (0)