Skip to content

Commit 16f9049

Browse files
committed
feat(auth): Add OTP authentication
1 parent f09862f commit 16f9049

File tree

11 files changed

+173
-17
lines changed

11 files changed

+173
-17
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@ dist
1919

2020
# VSCode
2121
.vscode/
22+
23+
service-account.json

.playground/app.config.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export default defineAppConfig({
2-
myLayer: {
3-
name: 'My amazing Nuxt layer (overwritten)'
4-
}
5-
})
2+
firebase: {
3+
authCollection: 'auth-otp',
4+
otpTTL: 1000 * 60 * 5, // 5 minutes
5+
},
6+
});

.playground/app.vue

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<script setup lang="ts">
2+
import { getAuth, signOut, signInWithCustomToken } from 'firebase/auth';
3+
4+
const otp = ref('');
5+
const email = ref('');
6+
const api = getApi();
7+
const user = useCurrentUser();
8+
function sendOTP() {
9+
api('/api/auth/otp', {
10+
method: 'POST',
11+
headers: {
12+
'Content-Type': 'application/json',
13+
},
14+
body: JSON.stringify({
15+
email: email.value,
16+
}),
17+
});
18+
}
19+
20+
function checkOTP() {
21+
api('/api/auth/otp/verify', {
22+
method: 'POST',
23+
headers: {
24+
'Content-Type': 'application/json',
25+
},
26+
body: JSON.stringify({
27+
otp: otp.value,
28+
email: email.value,
29+
}),
30+
})
31+
.then(({ token }) => {
32+
signInWithCustomToken(getAuth(), token)
33+
.then((userCredential) => {
34+
console.log(userCredential);
35+
});
36+
});
37+
}
38+
39+
function logout() {
40+
signOut(getAuth());
41+
}
42+
</script>
43+
<template>
44+
<div>
45+
<template v-if="user">
46+
<h1>Welcome, {{ user.email }}</h1>
47+
<button @click="logout()">
48+
Logout
49+
</button>
50+
</template>
51+
<template v-else>
52+
<form @submit.prevent="sendOTP">
53+
<input
54+
v-model="email"
55+
type="text"
56+
placeholder="email"
57+
>
58+
<button type="submit">
59+
Send OTP
60+
</button>
61+
</form>
62+
<form @submit.prevent="checkOTP">
63+
<input
64+
v-model="otp"
65+
type="text"
66+
placeholder="OTP"
67+
>
68+
<button>Check OTP</button>
69+
</form>
70+
</template>
71+
</div>
72+
</template>

.playground/nuxt.config.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { fileURLToPath } from 'node:url'
1+
import { fileURLToPath } from 'node:url';
22

33
export default defineNuxtConfig({
44
extends: ['..'],
55
modules: ['@nuxt/eslint'],
6+
compatibilityDate: '2024-12-16',
67
eslint: {
78
config: {
89
// Use the generated ESLint config for lint root project as well
9-
rootDir: fileURLToPath(new URL('..', import.meta.url))
10-
}
11-
}
12-
})
10+
rootDir: fileURLToPath(new URL('..', import.meta.url)),
11+
},
12+
},
13+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default defineSecuredEventHandler(async (event) => {
2+
const { email } = await readBody<{ email: string }>(event);
3+
await getOTP(email, event);
4+
setResponseStatus(event, 201);
5+
return { message: 'OTP sent' };
6+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export default defineSecuredEventHandler(async (event) => {
2+
const { email, otp } = await readBody<{ email: string; otp: string }>(event);
3+
return authOTP(email, otp, event);
4+
});

app.config.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
export default defineAppConfig({
2-
myLayer: {
3-
name: 'Hello from Nuxt layer',
2+
firebase: {
3+
authCollection: 'auth',
4+
otpTTL: 1000 * 60 * 5, // 5 minutes
45
},
56
});
67

78
declare module '@nuxt/schema' {
89
interface AppConfigInput {
9-
myLayer?: {
10+
firebase?: {
1011
/** Project name */
11-
name?: string
12+
authCollection?: string
13+
otpTTL?: number
1214
}
1315
}
1416
}

server/utils/auth/firebase.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { UserRecord } from 'firebase-admin/auth';
2+
import { getAuth } from 'firebase-admin/auth';
3+
import type { AppConfigInput } from 'nuxt/schema';
4+
import type { H3Event } from 'h3';
5+
6+
export async function getOTP(email: string, event: H3Event) {
7+
const config = useAppConfig(event);
8+
const user = await getUserByEmail(email);
9+
return generateOTP(user, config);
10+
}
11+
12+
export async function authOTP(email: string, otp: string, event: H3Event) {
13+
const config = useAppConfig(event);
14+
const user = await getAuth().getUserByEmail(email);
15+
await checkOtp(user, otp, config);
16+
17+
await getAuth().updateUser(user.uid, {
18+
emailVerified: true,
19+
});
20+
const token = await getAuth().createCustomToken(user.uid);
21+
22+
return { token };
23+
};
24+
25+
// eslint-disable-next-line complexity
26+
async function checkOtp(user: UserRecord, otp: string, config: AppConfigInput) {
27+
const doc = await documentsCollection(
28+
config.firebase?.authCollection || 'auth',
29+
).doc(user.uid).get();
30+
if (!doc.exists) throw createError({
31+
status: 404,
32+
statusMessage: 'OTP not found',
33+
});
34+
35+
const data = doc.data();
36+
37+
if (!data) throw createError({
38+
status: 404,
39+
statusMessage: 'OTP data not found',
40+
});
41+
42+
if (Number(data.otp) !== Number(otp) || new Date(data.ttl).getTime() < Date.now()) {
43+
throw createError({
44+
status: 401,
45+
statusMessage: 'Invalid OTP',
46+
});
47+
}
48+
await doc.ref.delete();
49+
}
50+
51+
async function getUserByEmail(email: string) {
52+
const user = await getAuth().getUserByEmail(email).catch(() => null);
53+
if (user) return user;
54+
return getAuth().createUser({
55+
email,
56+
emailVerified: false,
57+
});
58+
}
59+
60+
// eslint-disable-next-line complexity
61+
async function generateOTP(user: UserRecord, config: AppConfigInput) {
62+
const otp = Math.floor(100000 + Math.random() * 900000);
63+
await documentsCollection(config.firebase?.authCollection || 'auth').doc(user.uid).set({
64+
otp,
65+
ttl: Date.now() + (config.firebase?.otpTTL || 1000 * 60 * 5),
66+
});
67+
return otp;
68+
}

server/utils/auth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './firebase';

server/utils/backend/firebase.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import * as admin from 'firebase-admin';
1+
import admin from 'firebase-admin';
22

33
export async function initializeApp() {
44
if (import.meta.dev && process.env.GOOGLE_APPLICATION_CREDENTIALS) {
55
return admin.initializeApp({
6-
credential: admin.credential.cert(
7-
process.env.GOOGLE_APPLICATION_CREDENTIALS,
8-
),
6+
credential: admin.credential.cert(process.env.GOOGLE_APPLICATION_CREDENTIALS),
97
});
108
}
119
return admin.initializeApp();

0 commit comments

Comments
 (0)