Skip to content
This repository was archived by the owner on Sep 17, 2024. It is now read-only.

Commit a2a44ac

Browse files
committed
feat: Create verifications module to streamline auth_email_* and auth_sms_*
1 parent 31ebc1e commit a2a44ac

File tree

9 files changed

+463
-0
lines changed

9 files changed

+463
-0
lines changed

modules/verifications/db/schema.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { schema, Query } from "./schema.gen.ts";
2+
3+
export const verifications = schema.table('verifications', {
4+
id: Query.uuid("id").primaryKey().defaultRandom(),
5+
6+
data: Query.jsonb("data").notNull(),
7+
8+
code: Query.text("code").notNull().unique(),
9+
token: Query.text("token").notNull().unique(),
10+
11+
attemptCount: Query.integer("attempt_count").notNull().default(0),
12+
maxAttemptCount: Query.integer("max_attempt_count").notNull(),
13+
14+
createdAt: Query.timestamp("created_at").notNull().defaultNow(),
15+
expireAt: Query.timestamp("expire_at").notNull(),
16+
});
17+
18+
export const oldVerifications = schema.table('old_verifications', {
19+
id: Query.uuid("id").primaryKey(),
20+
21+
data: Query.jsonb("data").notNull(),
22+
23+
code: Query.text("code").notNull().unique(),
24+
token: Query.text("token").notNull().unique(),
25+
26+
attemptsCount: Query.integer("attempt_count").notNull(),
27+
wasCompleted: Query.boolean("was_completed").notNull(),
28+
29+
createdAt: Query.timestamp("created_at").notNull(),
30+
invalidatedAt: Query.timestamp("invalidated_at"),
31+
expiredAt: Query.timestamp("expired_at"),
32+
completedAt: Query.timestamp("completed_at"),
33+
});
34+

modules/verifications/module.json

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"status": "stable",
3+
"name": "Verifications",
4+
"description": "INTERNAL ONLY module — helper for OTP verifications (email/sms)",
5+
"icon": "key",
6+
"tags": [
7+
"auth"
8+
],
9+
"authors": [
10+
"Blckbrry-Pi"
11+
],
12+
"dependencies": {
13+
"tokens": {}
14+
},
15+
"scripts": {
16+
"create": {
17+
"name": "Create Verification",
18+
"public": false
19+
},
20+
"attempt": {
21+
"name": "Attempt to Complete Verification",
22+
"public": false
23+
},
24+
"invalidate": {
25+
"name": "Invalidate Verification",
26+
"public": false
27+
},
28+
"get": {
29+
"name": "Get Verification Information",
30+
"public": false
31+
}
32+
},
33+
"errors": {
34+
"unable_to_generate_unique_code": {
35+
"name": "Unable to Generate Unique Code",
36+
"description": "When generating a new code for a new verification, the script was unable to find a unique one.",
37+
"internal": true
38+
},
39+
"no_verification_found": {
40+
"name": "No verification found matching the required parameters",
41+
"description": "When attempting to find a verification, the database returned no matching results.",
42+
"internal": true
43+
},
44+
"failed_to_create": {
45+
"name": "Failed to Create New Verification",
46+
"description": "There was a database error while creating a new verification.",
47+
"internal": true
48+
},
49+
"failed_to_update": {
50+
"name": "Failed to Update Existing Verification",
51+
"description": "There was a database error while updating the information on an existing verification.",
52+
"internal": true
53+
},
54+
"unknown_err": {
55+
"name": "Unknown Error",
56+
"description": "There was an unexpected error of an unknown type.",
57+
"internal": true
58+
}
59+
}
60+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { ScriptContext, Query, Database, RuntimeError } from "../module.gen.ts";
2+
import { complete, moveToOldIfNecessary } from "../utils/migrate.ts";
3+
4+
export interface Request {
5+
token: string;
6+
code: string;
7+
}
8+
9+
export interface Response {
10+
succeeded: boolean;
11+
canTryAgain: boolean;
12+
data: unknown;
13+
}
14+
15+
export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
16+
try {
17+
const [verification] = await ctx.db.select()
18+
.from(Database.verifications)
19+
.where(Query.eq(Database.verifications.token, req.token));
20+
21+
if (!verification) throw new RuntimeError("no_verification_found");
22+
if (await moveToOldIfNecessary(ctx, verification.code)) throw new RuntimeError("no_verification_found");
23+
24+
let codeDiffers = Number(req.code.length !== verification.code.length);
25+
for (let i = 0; i < verification.code.length; i++) {
26+
codeDiffers |= verification.code.charCodeAt(i) ^ req.code.charCodeAt(i);
27+
}
28+
29+
if (codeDiffers) {
30+
await ctx.db.update(Database.verifications)
31+
.set({
32+
attemptCount: Query.sql`${Database.verifications.attemptCount} + 1`,
33+
})
34+
.where(Query.eq(Database.verifications.id, verification.id)).returning();
35+
const canTryAgain = !await moveToOldIfNecessary(ctx, verification.code);
36+
return {
37+
succeeded: false,
38+
canTryAgain,
39+
data: verification.data,
40+
}
41+
} else {
42+
await complete(ctx, verification.id);
43+
return {
44+
succeeded: true,
45+
canTryAgain: false,
46+
data: verification.data,
47+
}
48+
}
49+
} catch (e) {
50+
if (e instanceof RuntimeError) {
51+
throw e;
52+
} else if (e instanceof Query.DrizzleError) {
53+
throw new RuntimeError("failed_to_update");
54+
} else {
55+
throw new RuntimeError("unknown_err");
56+
}
57+
}
58+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { ScriptContext, Module, Database, RuntimeError, Query } from "../module.gen.ts";
2+
import { moveToOldIfNecessary } from "../utils/migrate.ts";
3+
4+
export interface Request {
5+
data: unknown;
6+
maxAttempts?: number;
7+
expireAt?: string;
8+
}
9+
10+
export interface Response {
11+
id: string;
12+
code: string;
13+
token: string;
14+
}
15+
16+
// This is very generous— we should definitely flush old verifications however
17+
const MAX_ATTEMPTS = 20;
18+
19+
export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
20+
const failedCodes = [];
21+
try {
22+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
23+
// Generate code + token cryptographically using the `tokens` public
24+
// functions
25+
const code = Module.tokens.generateRandomCodeSecure("0123456789", 8);
26+
const token = Module.tokens.genSecureId(32, Module.tokens.SecureIdFormat.HEX);
27+
28+
// Default to PG INT_MAX number of attempts
29+
const maxAttemptCount = req.maxAttempts ?? 0x7FFFFFFF;
30+
31+
// If expiry is left unspecified, set for 1 day from now
32+
const expireAt = req.expireAt ? new Date(req.expireAt) : new Date(Date.now() + 24 * 60 * 60 * 1000);
33+
34+
const [verification] = await ctx.db.insert(Database.verifications).values({
35+
// The identifying data for the insertion. Not necessarily unique.
36+
data: req.data,
37+
38+
code,
39+
token,
40+
maxAttemptCount,
41+
expireAt,
42+
}).returning({
43+
id: Database.verifications.id,
44+
code: Database.verifications.code,
45+
token: Database.verifications.token,
46+
}).onConflictDoNothing({
47+
target: Database.verifications.code,
48+
});
49+
50+
if (!verification) {
51+
failedCodes.push(code);
52+
continue;
53+
}
54+
return verification;
55+
}
56+
57+
throw new RuntimeError("unable_to_generate_unique_code");
58+
} catch (e) {
59+
if (e instanceof RuntimeError) {
60+
throw e;
61+
} else if (e instanceof Query.DrizzleError) {
62+
throw new RuntimeError("failed_to_create");
63+
} else {
64+
throw new RuntimeError("unknown_err");
65+
}
66+
} finally {
67+
await Promise.all(failedCodes.map(code => moveToOldIfNecessary(ctx, code)));
68+
}
69+
}

modules/verifications/scripts/get.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { ScriptContext, Query, Database, RuntimeError, UnreachableError } from "../module.gen.ts";
2+
import { Verification } from "../utils/migrate.ts";
3+
4+
interface IdRequest {
5+
id: string;
6+
}
7+
interface TokenRequest {
8+
token: string;
9+
}
10+
interface DataRequest {
11+
data: {};
12+
}
13+
14+
export type Request = IdRequest | TokenRequest | DataRequest;
15+
16+
export interface Response {
17+
verification?: Verification;
18+
}
19+
20+
export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
21+
let where: Query.SQL;
22+
if ("id" in req) {
23+
where = Query.eq(Database.verifications.id, req.id);
24+
} else if ("token" in req) {
25+
where = Query.eq(Database.verifications.token, req.token);
26+
} else if ("data" in req) {
27+
where = Query.eq(Database.verifications.data, req.data);
28+
} else {
29+
throw new UnreachableError(req);
30+
}
31+
try {
32+
const [verification] = await ctx.db.select()
33+
.from(Database.verifications)
34+
.where(where);
35+
return { verification };
36+
} catch (e) {
37+
throw new RuntimeError("unknown_err");
38+
}
39+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ScriptContext, Query, RuntimeError } from "../module.gen.ts";
2+
import { invalidate } from "../utils/migrate.ts";
3+
4+
export interface Request {
5+
token: string;
6+
}
7+
8+
export interface Response {
9+
data: unknown;
10+
}
11+
12+
export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
13+
try {
14+
const { data } = await invalidate(ctx, req.token);
15+
16+
return { data };
17+
} catch (e) {
18+
if (e instanceof RuntimeError) {
19+
throw e;
20+
} else if (e instanceof Query.DrizzleError) {
21+
throw new RuntimeError("failed_to_update");
22+
} else {
23+
throw new RuntimeError("unknown_err");
24+
}
25+
}
26+
}

modules/verifications/tests/e2e.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { test, TestContext, RuntimeError } from "../module.gen.ts";
2+
import { assert, assertEquals, assertRejects } from "https://deno.land/std@0.224.0/assert/mod.ts";
3+
import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts";
4+
5+
test("right_code_first_time", async (ctx: TestContext) => {
6+
const data = { email: faker.internet.email() };
7+
const createdVerification = await ctx.modules.verifications.create({ data });
8+
const attemptResult = await ctx.modules.verifications.attempt({
9+
token: createdVerification.token,
10+
code: createdVerification.code,
11+
});
12+
13+
assert(attemptResult.succeeded);
14+
assert(!attemptResult.canTryAgain);
15+
assertEquals(attemptResult.data, data);
16+
});
17+
18+
test("wrong_code_fail", async (ctx: TestContext) => {
19+
const data = { email: faker.internet.email() };
20+
21+
const createdVerification = await ctx.modules.verifications.create({ data });
22+
const attemptResult = await ctx.modules.verifications.attempt({
23+
token: createdVerification.token,
24+
code: "AAAAAAAA",
25+
});
26+
27+
assert(!attemptResult.succeeded, "Verification succeeded when it shouldn't have");
28+
assert(attemptResult.canTryAgain, "Verification should have more than 1 try");
29+
assertEquals(attemptResult.data, data, "Verification data did not match");
30+
});
31+
32+
test("overattempted", async (ctx: TestContext) => {
33+
const data = { email: faker.internet.email() };
34+
35+
const MAX_ATTEMPTS = 5;
36+
37+
const createdVerification = await ctx.modules.verifications.create({
38+
data,
39+
maxAttempts: MAX_ATTEMPTS,
40+
});
41+
42+
for (let i = 0; i < MAX_ATTEMPTS - 1; i++) {
43+
const attemptResult = await ctx.modules.verifications.attempt({
44+
token: createdVerification.token,
45+
code: "AAAAAAAA",
46+
});
47+
48+
assert(!attemptResult.succeeded, "Verification succeeded when it shouldn't have");
49+
assert(attemptResult.canTryAgain, "Verification ran out of tries earlier than it should have");
50+
assertEquals(attemptResult.data, data, "Verification data did not match");
51+
}
52+
53+
const attemptResult = await ctx.modules.verifications.attempt({
54+
token: createdVerification.token,
55+
code: "AAAAAAAA",
56+
});
57+
assert(!attemptResult.succeeded, "Verification succeeded when it shouldn't have");
58+
assert(!attemptResult.canTryAgain, "Verification should be out of tries");
59+
assertEquals(attemptResult.data, data, "Verification data did not match");
60+
61+
62+
const err = await assertRejects(() => ctx.modules.verifications.attempt({
63+
token: createdVerification.token,
64+
code: "AAAAAAAA",
65+
}), RuntimeError);
66+
67+
assertEquals(err.code, "no_verification_found");
68+
});
69+
70+
test("get_all_methods", async (ctx: TestContext) => {
71+
const data = { email: faker.internet.email() };
72+
73+
const createdVerification = await ctx.modules.verifications.create({ data });
74+
const getById = await ctx.modules.verifications.get({
75+
id: createdVerification.id,
76+
});
77+
const getByToken = await ctx.modules.verifications.get({
78+
token: createdVerification.token,
79+
});
80+
const getByData = await ctx.modules.verifications.get({
81+
data,
82+
});
83+
84+
assertEquals(getById, getByToken);
85+
assertEquals(getByToken, getByData);
86+
});

0 commit comments

Comments
 (0)