This repository has been archived by the owner on Sep 17, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
174206b
commit a2a1150
Showing
44 changed files
with
2,239 additions
and
128 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"fmt": { | ||
"useTabs": true | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
export interface Config { | ||
email?: EmailConfig; | ||
} | ||
|
||
export interface EmailConfig { | ||
fromEmail: string; | ||
fromName?: string; | ||
} |
48 changes: 48 additions & 0 deletions
48
modules/auth/db/migrations/20240310214734_init/migration.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
-- CreateTable | ||
CREATE TABLE "Identity" ( | ||
"userId" UUID NOT NULL, | ||
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
"deletedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
|
||
CONSTRAINT "Identity_pkey" PRIMARY KEY ("userId") | ||
); | ||
|
||
-- CreateTable | ||
CREATE TABLE "EmailPasswordless" ( | ||
"id" UUID NOT NULL, | ||
"identityId" UUID NOT NULL, | ||
"email" TEXT NOT NULL, | ||
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
|
||
CONSTRAINT "EmailPasswordless_pkey" PRIMARY KEY ("id") | ||
); | ||
|
||
-- CreateTable | ||
CREATE TABLE "EmailPasswordlessVerification" ( | ||
"id" UUID NOT NULL, | ||
"identityId" UUID, | ||
"email" TEXT NOT NULL, | ||
"code" TEXT NOT NULL, | ||
"attemptCount" INTEGER NOT NULL DEFAULT 0, | ||
"maxAttemptCount" INTEGER NOT NULL, | ||
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
"expireAt" TIMESTAMP NOT NULL, | ||
"completedAt" TIMESTAMP, | ||
|
||
CONSTRAINT "EmailPasswordlessVerification_pkey" PRIMARY KEY ("id") | ||
); | ||
|
||
-- CreateIndex | ||
CREATE UNIQUE INDEX "EmailPasswordless_email_key" ON "EmailPasswordless"("email"); | ||
|
||
-- CreateIndex | ||
CREATE UNIQUE INDEX "EmailPasswordlessVerification_code_key" ON "EmailPasswordlessVerification"("code"); | ||
|
||
-- AddForeignKey | ||
ALTER TABLE "EmailPasswordless" ADD CONSTRAINT "EmailPasswordless_identityId_fkey" FOREIGN KEY ("identityId") REFERENCES "Identity"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; | ||
|
||
-- AddForeignKey | ||
ALTER TABLE "EmailPasswordlessVerification" ADD CONSTRAINT "EmailPasswordlessVerification_email_fkey" FOREIGN KEY ("email") REFERENCES "EmailPasswordless"("email") ON DELETE RESTRICT ON UPDATE CASCADE; | ||
|
||
-- AddForeignKey | ||
ALTER TABLE "EmailPasswordlessVerification" ADD CONSTRAINT "EmailPasswordlessVerification_identityId_fkey" FOREIGN KEY ("identityId") REFERENCES "Identity"("userId") ON DELETE SET NULL ON UPDATE CASCADE; |
12 changes: 12 additions & 0 deletions
12
modules/auth/db/migrations/20240312024843_init/migration.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
/* | ||
Warnings: | ||
- You are about to drop the column `identityId` on the `EmailPasswordlessVerification` table. All the data in the column will be lost. | ||
*/ | ||
-- DropForeignKey | ||
ALTER TABLE "EmailPasswordlessVerification" DROP CONSTRAINT "EmailPasswordlessVerification_identityId_fkey"; | ||
|
||
-- AlterTable | ||
ALTER TABLE "EmailPasswordlessVerification" DROP COLUMN "identityId", | ||
ADD COLUMN "userId" UUID; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
-- DropForeignKey | ||
ALTER TABLE "EmailPasswordlessVerification" DROP CONSTRAINT "EmailPasswordlessVerification_email_fkey"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
/* | ||
Warnings: | ||
- You are about to drop the column `identityId` on the `EmailPasswordless` table. All the data in the column will be lost. | ||
- You are about to drop the `Identity` table. If the table is not empty, all the data it contains will be lost. | ||
- A unique constraint covering the columns `[userId]` on the table `EmailPasswordless` will be added. If there are existing duplicate values, this will fail. | ||
- Added the required column `userId` to the `EmailPasswordless` table without a default value. This is not possible if the table is not empty. | ||
*/ | ||
-- DropForeignKey | ||
ALTER TABLE "EmailPasswordless" DROP CONSTRAINT "EmailPasswordless_identityId_fkey"; | ||
|
||
-- AlterTable | ||
ALTER TABLE "EmailPasswordless" DROP COLUMN "identityId", | ||
ADD COLUMN "userId" UUID NOT NULL; | ||
|
||
-- DropTable | ||
DROP TABLE "Identity"; | ||
|
||
-- CreateIndex | ||
CREATE UNIQUE INDEX "EmailPasswordless_userId_key" ON "EmailPasswordless"("userId"); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Please do not edit this file manually | ||
# It should be added in your version-control system (i.e. Git) | ||
provider = "postgresql" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
datasource db { | ||
provider = "postgresql" | ||
url = env("DATABASE_URL") | ||
} | ||
|
||
model EmailPasswordless { | ||
id String @id @default(uuid()) @db.Uuid | ||
userId String @db.Uuid @unique | ||
email String @unique | ||
createdAt DateTime @default(now()) @db.Timestamp | ||
} | ||
|
||
model EmailPasswordlessVerification { | ||
id String @id @default(uuid()) @db.Uuid | ||
// If exists, link to existing identity. If null, create new identity. | ||
userId String? @db.Uuid | ||
email String | ||
// Code the user has to input to verify the email | ||
code String @unique | ||
attemptCount Int @default(0) | ||
maxAttemptCount Int | ||
createdAt DateTime @default(now()) @db.Timestamp | ||
expireAt DateTime @db.Timestamp | ||
completedAt DateTime? @db.Timestamp | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
dependencies: | ||
email: {} | ||
users: {} | ||
rate_limit: {} | ||
scripts: | ||
auth_email_passwordless: | ||
public: true | ||
verify_email_passwordless: | ||
public: true | ||
errors: | ||
PROVIDER_DISABLED: {} | ||
VERIFICATION_CODE_INVALID: {} | ||
VERIFICATION_CODE_ATTEMPT_LIMIT: {} | ||
VERIFICATION_CODE_EXPIRED: {} | ||
VERIFICATION_CODE_ALREADY_USED: {} | ||
EMAIL_ALREADY_USED: {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
import { RuntimeError } from "../_gen/mod.ts"; | ||
import { ScriptContext } from "../_gen/scripts/auth_email_passwordless.ts"; | ||
import { Verification } from "../utils/types.ts"; | ||
|
||
export interface Request { | ||
email: string; | ||
userToken?: string; | ||
} | ||
|
||
export interface Response { | ||
verification: Verification; | ||
} | ||
|
||
export async function run( | ||
ctx: ScriptContext, | ||
req: Request, | ||
): Promise<Response> { | ||
if (!ctx.userConfig.email) throw new RuntimeError("PROVIDER_DISABLED"); | ||
|
||
await ctx.modules.rateLimit.throttle({}); | ||
|
||
// Fetch existing user if session token is provided | ||
let userId: string | undefined; | ||
if (req.userToken) { | ||
const { userId } = await ctx.modules.users.validateUserToken({ | ||
userToken: req.userToken, | ||
}); | ||
|
||
// Check if the email is already associated with an identity | ||
const existingIdentity = await ctx.db.emailPasswordless.findFirst({ | ||
where: { email: req.email }, | ||
}); | ||
if (existingIdentity && existingIdentity.userId !== userId) { | ||
throw new RuntimeError("EMAIL_ALREADY_USED"); | ||
} | ||
} | ||
|
||
// Create verification | ||
const code = generateCode(); | ||
const maxAttemptCount = 3; | ||
const expiration = 60 * 60 * 1000; | ||
const verification = await ctx.db.emailPasswordlessVerification.create({ | ||
data: { | ||
userId, | ||
email: req.email, | ||
code, | ||
maxAttemptCount, | ||
expireAt: new Date(Date.now() + expiration), | ||
}, | ||
select: { id: true }, | ||
}); | ||
|
||
// Send email | ||
await ctx.modules.email.sendEmail({ | ||
from: { | ||
email: ctx.userConfig.email.fromEmail ?? "[email protected]", | ||
name: ctx.userConfig.email.fromName ?? "Authentication Code", | ||
}, | ||
to: [{ email: req.email }], | ||
subject: "Your verification code", | ||
text: `Your verification code is: ${code}`, | ||
html: `Your verification code is: <b>${code}</b>`, | ||
}); | ||
|
||
return { verification }; | ||
} | ||
|
||
function generateCode(): string { | ||
const length = 8; | ||
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; | ||
let result = ""; | ||
for (let i = 0; i < length; i++) { | ||
result += characters.charAt(Math.floor(Math.random() * characters.length)); | ||
} | ||
return result; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import { assertExists } from "https://deno.land/[email protected]/assert/mod.ts"; | ||
import { | ||
RuntimeError, | ||
ScriptContext, | ||
} from "../_gen/scripts/verify_email_passwordless.ts"; | ||
import { TokenWithSecret } from "../../tokens/types/common.ts"; | ||
|
||
export interface Request { | ||
verificationId: string; | ||
code: string; | ||
} | ||
|
||
export interface Response { | ||
token: TokenWithSecret; | ||
} | ||
|
||
export async function run( | ||
ctx: ScriptContext, | ||
req: Request, | ||
): Promise<Response> { | ||
await ctx.modules.rateLimit.throttle({}); | ||
|
||
const code = req.code.toUpperCase(); | ||
|
||
// Validate & mark as used | ||
let userId: string | undefined; | ||
await ctx.db.$transaction(async (tx) => { | ||
const verification = await tx.emailPasswordlessVerification.update({ | ||
where: { | ||
id: req.verificationId, | ||
}, | ||
data: { | ||
attemptCount: { | ||
increment: 1, | ||
}, | ||
}, | ||
select: { | ||
email: true, | ||
userId: true, | ||
code: true, | ||
expireAt: true, | ||
completedAt: true, | ||
attemptCount: true, | ||
maxAttemptCount: true, | ||
}, | ||
}); | ||
if (!verification) { | ||
throw new RuntimeError("VERIFICATION_CODE_INVALID"); | ||
} | ||
if (verification.attemptCount >= verification.maxAttemptCount) { | ||
throw new RuntimeError("VERIFICATION_CODE_ATTEMPT_LIMIT"); | ||
} | ||
if (verification.completedAt !== null) { | ||
throw new RuntimeError("VERIFICATION_CODE_ALREADY_USED"); | ||
} | ||
if (verification.code !== code) { | ||
// Same error as above to prevent exploitation | ||
throw new RuntimeError("VERIFICATION_CODE_INVALID"); | ||
} | ||
if (verification.expireAt < new Date()) { | ||
throw new RuntimeError("VERIFICATION_CODE_EXPIRED"); | ||
} | ||
|
||
// Mark as used | ||
const verificationConfirmation = await tx.emailPasswordlessVerification | ||
.update({ | ||
where: { | ||
id: req.verificationId, | ||
completedAt: null, | ||
}, | ||
data: { | ||
completedAt: new Date(), | ||
}, | ||
}); | ||
if (verificationConfirmation === null) { | ||
throw new RuntimeError("VERIFICATION_CODE_ALREADY_USED"); | ||
} | ||
|
||
// Get or create user | ||
if (verification.userId) { | ||
userId = verification.userId; | ||
} else { | ||
const { user } = await ctx.modules.users.createUser({}); | ||
userId = user.id; | ||
} | ||
|
||
// Create identity | ||
await tx.emailPasswordless.upsert({ | ||
where: { | ||
email: verification.email, | ||
userId, | ||
}, | ||
create: { | ||
email: verification.email, | ||
userId, | ||
}, | ||
update: {}, | ||
}); | ||
}); | ||
assertExists(userId); | ||
|
||
// Create token | ||
const { token } = await ctx.modules.users.createUserToken({ userId }); | ||
|
||
return { token }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import { test, TestContext } from "../_gen/test.ts"; | ||
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts"; | ||
import { faker } from "https://deno.land/x/[email protected]/mod.ts"; | ||
|
||
test("e2e", async (ctx: TestContext) => { | ||
const authRes = await ctx.modules.auth.authEmailPasswordless({ | ||
email: faker.internet.email(), | ||
}); | ||
|
||
// Look up correct code | ||
const { code } = await ctx.db.emailPasswordlessVerification.findFirstOrThrow({ | ||
where: { | ||
id: authRes.verification.id, | ||
}, | ||
}); | ||
|
||
const verifyRes = await ctx.modules.auth.verifyEmailPasswordless({ | ||
verificationId: authRes.verification.id, | ||
code: code, | ||
}); | ||
assertEquals(verifyRes.token.type, "user"); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
export interface Verification { | ||
id: string; | ||
} | ||
|
||
export interface Session { | ||
token: string; | ||
expireAt: string; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.