From 81cf4a9c40d0db8546b9155be966ca6a94173344 Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Thu, 27 Jun 2024 13:47:50 -0400 Subject: [PATCH] feat: Stub the new modular `auth` module and set up flow management. --- modules/auth/config.ts | 9 +- .../20240310214734_init/migration.sql | 48 ------- .../20240312024843_init/migration.sql | 12 -- .../migrations/20240312033322_/migration.sql | 2 - .../migrations/20240312035811_/migration.sql | 21 --- .../20240627174615_init_stub/migration.sql | 22 +++ modules/auth/db/schema.prisma | 32 ++--- modules/auth/module.json | 119 +++++++++------- modules/auth/scripts/cancel_flow.ts | 15 ++ .../scripts/complete_email_verification.ts | 106 -------------- modules/auth/scripts/complete_flow.ts | 19 +++ modules/auth/scripts/get_flow_status.ts | 18 +++ modules/auth/scripts/list_identities.ts | 11 ++ modules/auth/scripts/list_providers.ts | 15 ++ .../auth/scripts/send_email_verification.ts | 80 ----------- modules/auth/scripts/start_login_flow.ts | 16 +++ modules/auth/tests/e2e.ts | 75 ---------- modules/auth/utils/flow.ts | 129 ++++++++++++++++++ modules/auth/utils/types.ts | 17 ++- 19 files changed, 342 insertions(+), 424 deletions(-) delete mode 100644 modules/auth/db/migrations/20240310214734_init/migration.sql delete mode 100644 modules/auth/db/migrations/20240312024843_init/migration.sql delete mode 100644 modules/auth/db/migrations/20240312033322_/migration.sql delete mode 100644 modules/auth/db/migrations/20240312035811_/migration.sql create mode 100644 modules/auth/db/migrations/20240627174615_init_stub/migration.sql create mode 100644 modules/auth/scripts/cancel_flow.ts delete mode 100644 modules/auth/scripts/complete_email_verification.ts create mode 100644 modules/auth/scripts/complete_flow.ts create mode 100644 modules/auth/scripts/get_flow_status.ts create mode 100644 modules/auth/scripts/list_identities.ts create mode 100644 modules/auth/scripts/list_providers.ts delete mode 100644 modules/auth/scripts/send_email_verification.ts create mode 100644 modules/auth/scripts/start_login_flow.ts create mode 100644 modules/auth/utils/flow.ts diff --git a/modules/auth/config.ts b/modules/auth/config.ts index 74110e30..38ed251d 100644 --- a/modules/auth/config.ts +++ b/modules/auth/config.ts @@ -1,8 +1,5 @@ -export interface Config { - email?: EmailConfig; -} +import { Provider } from "./utils/types.ts"; -export interface EmailConfig { - fromEmail: string; - fromName?: string; +export interface Config { + providers: Provider[]; } diff --git a/modules/auth/db/migrations/20240310214734_init/migration.sql b/modules/auth/db/migrations/20240310214734_init/migration.sql deleted file mode 100644 index 083e45d2..00000000 --- a/modules/auth/db/migrations/20240310214734_init/migration.sql +++ /dev/null @@ -1,48 +0,0 @@ --- 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; diff --git a/modules/auth/db/migrations/20240312024843_init/migration.sql b/modules/auth/db/migrations/20240312024843_init/migration.sql deleted file mode 100644 index c268e246..00000000 --- a/modules/auth/db/migrations/20240312024843_init/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ -/* - 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; diff --git a/modules/auth/db/migrations/20240312033322_/migration.sql b/modules/auth/db/migrations/20240312033322_/migration.sql deleted file mode 100644 index ba914d3a..00000000 --- a/modules/auth/db/migrations/20240312033322_/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- DropForeignKey -ALTER TABLE "EmailPasswordlessVerification" DROP CONSTRAINT "EmailPasswordlessVerification_email_fkey"; diff --git a/modules/auth/db/migrations/20240312035811_/migration.sql b/modules/auth/db/migrations/20240312035811_/migration.sql deleted file mode 100644 index 32460756..00000000 --- a/modules/auth/db/migrations/20240312035811_/migration.sql +++ /dev/null @@ -1,21 +0,0 @@ -/* - 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"); diff --git a/modules/auth/db/migrations/20240627174615_init_stub/migration.sql b/modules/auth/db/migrations/20240627174615_init_stub/migration.sql new file mode 100644 index 00000000..0ab8403c --- /dev/null +++ b/modules/auth/db/migrations/20240627174615_init_stub/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "IdentityEmail" ( + "userId" UUID NOT NULL, + "email" TEXT NOT NULL, + + CONSTRAINT "IdentityEmail_pkey" PRIMARY KEY ("email") +); + +-- CreateTable +CREATE TABLE "IdentityOAuth" ( + "userId" UUID NOT NULL, + "provider" TEXT NOT NULL, + "subId" TEXT NOT NULL, + + CONSTRAINT "IdentityOAuth_pkey" PRIMARY KEY ("provider","subId") +); + +-- CreateIndex +CREATE INDEX "IdentityEmail_userId_idx" ON "IdentityEmail"("userId"); + +-- CreateIndex +CREATE INDEX "IdentityOAuth_userId_idx" ON "IdentityOAuth"("userId"); diff --git a/modules/auth/db/schema.prisma b/modules/auth/db/schema.prisma index 6b8d0de2..058dc2a3 100644 --- a/modules/auth/db/schema.prisma +++ b/modules/auth/db/schema.prisma @@ -3,28 +3,16 @@ datasource db { 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 IdentityEmail { + userId String @db.Uuid + email String @id + @@index([userId]) } -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 +model IdentityOAuth { + userId String @db.Uuid + provider String + subId String + @@id([provider, subId]) + @@index([userId]) } diff --git a/modules/auth/module.json b/modules/auth/module.json index 0b7d940f..f5df9828 100644 --- a/modules/auth/module.json +++ b/modules/auth/module.json @@ -1,52 +1,71 @@ { - "name": "Authentication", - "description": "Authenticate users with multiple authentication methods.", - "icon": "key", - "tags": [ - "core", - "auth", - "user" - ], - "authors": [ - "rivet-gg", - "NathanFlurry" - ], - "status": "stable", - "dependencies": { - "email": {}, - "users": {}, - "rate_limit": {} - }, - "scripts": { - "send_email_verification": { - "name": "Send Email Verification", - "description": "Send a one-time verification code to a user's email address to authenticate them.", - "public": true - }, - "complete_email_verification": { - "name": "Complete Email Verification", - "description": "Verify a user's email address with a one-time verification code.", - "public": true - } - }, - "errors": { - "provider_disabled": { - "name": "Provider Disabled" - }, - "verification_code_invalid": { - "name": "Verification Code Invalid" - }, - "verification_code_attempt_limit": { - "name": "Verification Code Attempt Limit" - }, - "verification_code_expired": { - "name": "Verification Code Expired" - }, - "verification_code_already_used": { - "name": "Verification Code Already Used" - }, - "email_already_used": { - "name": "Email Already Used" - } - } + "name": "Authentication", + "description": "Authenticate users with multiple authentication methods.", + "icon": "key", + "tags": [ + "core", + "auth", + "user" + ], + "authors": [ + "rivet-gg", + "NathanFlurry" + ], + "status": "stable", + "dependencies": { + "email": {}, + "users": {}, + "rate_limit": {}, + "tokens": {} + }, + "scripts": { + "get_flow_status": { + "name": "Get Flow Status", + "description": "Get the status of a login flow by the flow token. Returns the userToken if the flow is completed.", + "public": true + }, + "cancel_flow": { + "name": "Cancel Flow", + "description": "Cancels a login flow. This is irreversible and will error if the flow is not `pending`." + }, + "complete_flow": { + "name": "Complete Flow", + "description": "Completes a login flow and generates a user token. This is irreversible and will error if the flow is not `pending`." + }, + "list_providers": { + "name": "Send Email Verification", + "description": "Send a one-time verification code to a user's email address to authenticate them.", + "public": true + }, + "start_login_flow": { + "name": "Send Email Verification", + "description": "Send a one-time verification code to a user's email address to authenticate them.", + "public": true + }, + "list_identities": { + "name": "Complete Email Verification", + "description": "Verify a user's email address with a one-time verification code.", + "public": true + } + }, + "errors": { + "provider_disabled": { + "name": "Provider Disabled" + }, + "verification_code_invalid": { + "name": "Verification Code Invalid" + }, + "verification_code_attempt_limit": { + "name": "Verification Code Attempt Limit" + }, + "verification_code_expired": { + "name": "Verification Code Expired" + }, + "verification_code_already_used": { + "name": "Verification Code Already Used" + }, + "email_already_used": { + "name": "Email Already Used" + } + } } diff --git a/modules/auth/scripts/cancel_flow.ts b/modules/auth/scripts/cancel_flow.ts new file mode 100644 index 00000000..2ae40de7 --- /dev/null +++ b/modules/auth/scripts/cancel_flow.ts @@ -0,0 +1,15 @@ +import { Empty, ScriptContext } from "../module.gen.ts"; +import { cancelFlow } from "../utils/flow.ts"; + +export interface Request { + flowToken: string; +} +export type Response = Empty; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await cancelFlow(ctx, req.flowToken); + return {}; +} diff --git a/modules/auth/scripts/complete_email_verification.ts b/modules/auth/scripts/complete_email_verification.ts deleted file mode 100644 index 684914b7..00000000 --- a/modules/auth/scripts/complete_email_verification.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { assertExists } from "https://deno.land/std@0.208.0/assert/mod.ts"; -import { - RuntimeError, - ScriptContext, -} from "../module.gen.ts"; -import { TokenWithSecret } from "../../tokens/utils/types.ts"; - -export interface Request { - verificationId: string; - code: string; -} - -export interface Response { - token: TokenWithSecret; -} - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - 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.create({}); - 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.createToken({ userId }); - - return { token }; -} diff --git a/modules/auth/scripts/complete_flow.ts b/modules/auth/scripts/complete_flow.ts new file mode 100644 index 00000000..b6c6fbc9 --- /dev/null +++ b/modules/auth/scripts/complete_flow.ts @@ -0,0 +1,19 @@ +import { ScriptContext } from "../module.gen.ts"; +import { completeFlow } from "../utils/flow.ts"; + +export interface Request { + flowToken: string; + userId: string; +} +export interface Response { + userToken: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + return { + userToken: await completeFlow(ctx, req.flowToken, req.userId), + }; +} diff --git a/modules/auth/scripts/get_flow_status.ts b/modules/auth/scripts/get_flow_status.ts new file mode 100644 index 00000000..b97f3d21 --- /dev/null +++ b/modules/auth/scripts/get_flow_status.ts @@ -0,0 +1,18 @@ +import { ScriptContext } from "../module.gen.ts"; +import { FlowStatus, getFlowStatus } from "../utils/flow.ts"; + +export interface Request { + flowToken: string; +} +export interface Response { + status: FlowStatus; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + return { + status: await getFlowStatus(ctx, req.flowToken), + }; +} diff --git a/modules/auth/scripts/list_identities.ts b/modules/auth/scripts/list_identities.ts new file mode 100644 index 00000000..b2aa2371 --- /dev/null +++ b/modules/auth/scripts/list_identities.ts @@ -0,0 +1,11 @@ +import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts"; + +export type Request = Empty; +export type Response = Empty; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + throw new RuntimeError("todo", { statusCode: 500 }); +} diff --git a/modules/auth/scripts/list_providers.ts b/modules/auth/scripts/list_providers.ts new file mode 100644 index 00000000..22c9d630 --- /dev/null +++ b/modules/auth/scripts/list_providers.ts @@ -0,0 +1,15 @@ +import { Empty, ScriptContext } from "../module.gen.ts"; +import { Provider } from "../utils/types.ts"; + +export type Request = Empty; +export interface Response { + providers: Provider[]; +} + +// deno-lint-ignore require-await +export async function run( + ctx: ScriptContext, + _: Request, +): Promise { + return { providers: ctx.config.providers }; +} diff --git a/modules/auth/scripts/send_email_verification.ts b/modules/auth/scripts/send_email_verification.ts deleted file mode 100644 index 9cdb527b..00000000 --- a/modules/auth/scripts/send_email_verification.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { RuntimeError } from "../module.gen.ts"; -import { ScriptContext } from "../module.gen.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 { - await ctx.modules.rateLimit.throttlePublic({}); - - if (!ctx.config.email) throw new RuntimeError("provider_disabled"); - - // Check if the email is already associated with an identity - const existingIdentity = await ctx.db.emailPasswordless.findFirst({ - where: { email: req.email }, - }); - - // Fetch existing user if session token is provided - let userId: string | undefined = existingIdentity?.userId; - - if (req.userToken) { - const authRes = await ctx.modules.users.authenticateToken({ - userToken: req.userToken, - }); - - if (existingIdentity && existingIdentity.userId !== authRes.userId) { - throw new RuntimeError("email_already_used"); - } - - userId = authRes.userId; - } - - // 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.config.email.fromEmail ?? "hello@test.com", - name: ctx.config.email.fromName ?? "Authentication Code", - }, - to: [{ email: req.email }], - subject: "Your verification code", - text: `Your verification code is: ${code}`, - html: `Your verification code is: ${code}`, - }); - - 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; -} diff --git a/modules/auth/scripts/start_login_flow.ts b/modules/auth/scripts/start_login_flow.ts new file mode 100644 index 00000000..3bac344d --- /dev/null +++ b/modules/auth/scripts/start_login_flow.ts @@ -0,0 +1,16 @@ +import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts"; +import { Provider } from "../utils/types.ts"; + +export interface Request { + provider: Provider; +} +export interface Response { + urlForLoginLink: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + throw new RuntimeError("todo", { statusCode: 500 }); +} diff --git a/modules/auth/tests/e2e.ts b/modules/auth/tests/e2e.ts index 7242c6b7..e69de29b 100644 --- a/modules/auth/tests/e2e.ts +++ b/modules/auth/tests/e2e.ts @@ -1,75 +0,0 @@ -import { test, TestContext } from "../module.gen.ts"; -import { assertEquals } from "https://deno.land/std@0.208.0/assert/mod.ts"; -import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; - -test("e2e", async (ctx: TestContext) => { - // First we create a new user, and "register" into the auth - // using an sendEmailVerification({ email, userToken }) - // call - const { user } = await ctx.modules.users.create({}); - - const { token: session } = await ctx.modules.users.createToken({ - userId: user.id - }); - - const fakeEmail = faker.internet.email(); - - // Now we test that post-signin, we get the same user - { - const authRes = await ctx.modules.auth.sendEmailVerification({ - email: fakeEmail, - userToken: session.token - }); - - // Look up correct code - const { code } = await ctx.db.emailPasswordlessVerification.findFirstOrThrow({ - where: { - id: authRes.verification.id, - }, - }); - - // Now by verifying the email, we register, and can also use - // this to verify the token - const verifyRes = await ctx.modules.auth.completeEmailVerification({ - verificationId: authRes.verification.id, - code: code, - }); - - assertEquals(verifyRes.token.type, "user"); - - - // Make sure we end up with the same user we started with - const verifyRes2 = await ctx.modules.users.authenticateToken({ - userToken: verifyRes.token.token - }); - - assertEquals(verifyRes2.userId, user.id); - } - - // Now we try logging back in with the same email, - // but without a token, expecting the same user - { - const authRes = await ctx.modules.auth.sendEmailVerification({ - email: fakeEmail - }); - - // Look up correct code - const { code: code } = await ctx.db.emailPasswordlessVerification.findFirstOrThrow({ - where: { - id: authRes.verification.id, - }, - }); - - const verifyRes = await ctx.modules.auth.completeEmailVerification({ - verificationId: authRes.verification.id, - code: code, - }); - - const verifyRes2 = await ctx.modules.users.authenticateToken({ - userToken: verifyRes.token.token - }); - - assertEquals(verifyRes2.userId, user.id); - } -}); - diff --git a/modules/auth/utils/flow.ts b/modules/auth/utils/flow.ts new file mode 100644 index 00000000..a2e5e00d --- /dev/null +++ b/modules/auth/utils/flow.ts @@ -0,0 +1,129 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; + +/** + * The token type that designates that this is a flow token + */ +const FLOW_TYPE = "auth_flow"; + +/** + * Number of seconds after flow start that the flow will cease to be valid. + * + * This is currently hardcoded to 30 minutes, but it may be configurable in the + * future. + */ +const FLOW_EXPIRE_TIME = 30 * 60; + +/** + * Calculates when the flow should expire using the current server time. + * + * Leap seconds are not accounted for because they really don't matter. + * + * @returns The `Date` object for when the flow should expire. + */ +function getExpiryTime() { + const expiryTimeMs = Date.now() + FLOW_EXPIRE_TIME * 1000; + return new Date(expiryTimeMs); +} + +/** + * @param ctx The ScriptContext with which to call tokens.create + * @returns A flow token (TokenWithSecret) with the correct meta and expiry + * time. + */ +export async function createFlowToken(ctx: ScriptContext) { + const { token } = await ctx.modules.tokens.create({ + type: FLOW_TYPE, + meta: {}, + expireAt: getExpiryTime().toString(), + }); + return token; +} + +export type FlowStatus = + | { + status: "complete"; + userToken: string; + } + | { status: "pending" } + | { status: "expired" } + | { status: "cancelled" }; + +export async function getFlowStatus( + ctx: ScriptContext, + flowToken: string, +): Promise { + const { tokens: [flowData] } = await ctx.modules.tokens.fetchByToken({ + tokens: [flowToken], + }); + + if (!flowData || flowData.type !== FLOW_TYPE) { + throw new RuntimeError("flow_not_found"); + } + + // NOTE: Any tokens without an expiry date will always be expired + const expireDate = flowData.expireAt + ? new Date(flowData.expireAt) + : new Date(0); + + if (flowData.revokedAt) { + return { status: "cancelled" }; + } else if (expireDate.getTime() <= Date.now()) { + return { status: "expired" }; + } else if (!flowData.meta.userToken) { + return { status: "pending" }; + } else { + return { + status: "complete", + userToken: flowData.meta.userToken.toString(), + }; + } +} + +export async function cancelFlow( + ctx: ScriptContext, + flowToken: string, +): Promise { + const status = await getFlowStatus(ctx, flowToken); + const { tokens: [{ id: flowId }] } = await ctx.modules.tokens.fetchByToken({ + tokens: [flowToken], + }); + + switch (status.status) { + case "complete": + throw new RuntimeError("already_completed"); + case "expired": + throw new RuntimeError("flow_expired"); + case "cancelled": + throw new RuntimeError("flow_cancelled"); + + case "pending": + await ctx.modules.tokens.revoke({ tokenIds: [flowId] }); + return; + } +} + +export async function completeFlow( + ctx: ScriptContext, + flowToken: string, + userId: string, +): Promise { + const status = await getFlowStatus(ctx, flowToken); + switch (status.status) { + case "complete": + throw new RuntimeError("already_completed"); + case "expired": + throw new RuntimeError("flow_expired"); + case "cancelled": + throw new RuntimeError("flow_cancelled"); + + case "pending": + break; + } + const { token } = await ctx.modules.users.createToken({ userId }); + await ctx.modules.tokens.modifyMeta({ + token: flowToken, + newMeta: { userToken: token.token }, + }); + + return token.token; +} diff --git a/modules/auth/utils/types.ts b/modules/auth/utils/types.ts index 6e0691fc..a53cf2e8 100644 --- a/modules/auth/utils/types.ts +++ b/modules/auth/utils/types.ts @@ -1,8 +1,21 @@ -export interface Verification { - id: string; +export interface FlowToken { + token: string; } export interface Session { token: string; expireAt: string; } + +export enum ProviderType { + EMAIL = "email", + OAUTH = "oauth", +} + +export type EmailProvider = Record< + ProviderType.EMAIL, + { passwordless: boolean } +>; +export type OAuthProvider = Record; + +export type Provider = EmailProvider | OAuthProvider;