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

Commit 709f613

Browse files
committed
feat: Create an OAuth2 module for authenticating users
1 parent 4b1fde3 commit 709f613

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+2133
-15
lines changed

modules/auth_email/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export interface Config {
2+
enable: {
3+
passwordless?: boolean;
4+
withPassword?: boolean;
5+
linking?: boolean;
6+
};
7+
fromEmail?: string;
8+
fromName?: string;
9+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- CreateTable
2+
CREATE TABLE "Verifications" (
3+
"id" UUID NOT NULL,
4+
"email" TEXT NOT NULL,
5+
"code" TEXT NOT NULL,
6+
"token" TEXT NOT NULL,
7+
"attemptCount" INTEGER NOT NULL DEFAULT 0,
8+
"maxAttemptCount" INTEGER NOT NULL,
9+
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
"expireAt" TIMESTAMP NOT NULL,
11+
"completedAt" TIMESTAMP,
12+
13+
CONSTRAINT "Verifications_pkey" PRIMARY KEY ("id")
14+
);
15+
16+
-- CreateIndex
17+
CREATE UNIQUE INDEX "Verifications_code_key" ON "Verifications"("code");
18+
19+
-- CreateIndex
20+
CREATE UNIQUE INDEX "Verifications_token_key" ON "Verifications"("token");
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Please do not edit this file manually
2+
# It should be added in your version-control system (i.e. Git)
3+
provider = "postgresql"

modules/auth_email/db/schema.prisma

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
datasource db {
2+
provider = "postgresql"
3+
url = env("DATABASE_URL")
4+
}
5+
6+
model Verifications {
7+
id String @id @default(uuid()) @db.Uuid
8+
9+
email String
10+
11+
// Code the user has to input to verify the email
12+
code String @unique
13+
token String @unique
14+
15+
attemptCount Int @default(0)
16+
maxAttemptCount Int
17+
18+
createdAt DateTime @default(now()) @db.Timestamp
19+
expireAt DateTime @db.Timestamp
20+
completedAt DateTime? @db.Timestamp
21+
}

modules/auth_email/module.json

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
{
2+
"name": "Auth Email",
3+
"description": "Authenticate users with email only or an email/password combination.",
4+
"icon": "key",
5+
"tags": [
6+
"core",
7+
"auth",
8+
"user"
9+
],
10+
"authors": [
11+
"rivet-gg",
12+
"NathanFlurry",
13+
"Blckbrry-Pi"
14+
],
15+
"status": "stable",
16+
"dependencies": {
17+
"email": {},
18+
"identities": {},
19+
"users": {},
20+
"tokens": {},
21+
"user_passwords": {},
22+
"rate_limit": {}
23+
},
24+
"defaultConfig": {
25+
"enable": {
26+
"withPassword": true,
27+
"passwordless": true,
28+
"linking": true
29+
},
30+
"fromEmail": "hello@test.com",
31+
"fromName": "Authentication Code"
32+
},
33+
"scripts": {
34+
"send_verification": {
35+
"name": "Send Email Verification (No Password)",
36+
"description": "Send a one-time verification code to an email address to verify ownership. Does not require a password.",
37+
"public": true
38+
},
39+
"verify_add_no_pass": {
40+
"name": "Verify and Add Email to Existing User (No Password)",
41+
"description": "Verify a user's email address and register it with an existing account. Does not require a password.",
42+
"public": true
43+
},
44+
"verify_login_or_create_no_pass": {
45+
"name": "Verify and Login as (or Create) User (No Password)",
46+
"description": "Verify the email address code and return a userToken to AN account (creates a new account if one doesn't exist). Does not require a password.",
47+
"public": true
48+
},
49+
"verify_link_email": {
50+
"name": "Verify and Link Email Address to User",
51+
"description": "Verify a user's email address and link it to an existing account without allowing login passwordless.",
52+
"public": true
53+
},
54+
55+
"sign_up_email_pass": {
56+
"name": "Verify and Sign Up with Email and Password",
57+
"description": "Sign up a new user with an email and password.",
58+
"public": true
59+
},
60+
"sign_in_email_pass": {
61+
"name": "Sign In with Email and Password",
62+
"description": "Sign in a user with an email and password.",
63+
"public": true
64+
},
65+
"verify_add_email_pass": {
66+
"name": "Verify and Add Email and Password to existing user",
67+
"description": "Verify a user's email address and register it with an existing account. Requires a password.",
68+
"public": true
69+
}
70+
},
71+
"errors": {
72+
"provider_disabled": {
73+
"name": "Provider Disabled"
74+
},
75+
"verification_code_invalid": {
76+
"name": "Verification Code Invalid"
77+
},
78+
"verification_code_attempt_limit": {
79+
"name": "Verification Code Attempt Limit"
80+
},
81+
"verification_code_expired": {
82+
"name": "Verification Code Expired"
83+
},
84+
"verification_code_already_used": {
85+
"name": "Verification Code Already Used"
86+
},
87+
"email_already_used": {
88+
"name": "Email Already Used"
89+
}
90+
}
91+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { ScriptContext } from "../module.gen.ts";
2+
import { createVerification } from "../utils/code_management.ts";
3+
import { Verification } from "../utils/types.ts";
4+
5+
export interface Request {
6+
email: string;
7+
userToken?: string;
8+
}
9+
10+
export interface Response {
11+
verification: Verification;
12+
}
13+
14+
export async function run(
15+
ctx: ScriptContext,
16+
req: Request,
17+
): Promise<Response> {
18+
await ctx.modules.rateLimit.throttlePublic({});
19+
20+
const { code, verification } = await createVerification(
21+
ctx,
22+
req.email,
23+
);
24+
25+
// Send email
26+
await ctx.modules.email.sendEmail({
27+
from: {
28+
email: ctx.config.fromEmail ?? "hello@test.com",
29+
name: ctx.config.fromName ?? "Authentication Code",
30+
},
31+
to: [{ email: req.email }],
32+
subject: "Your verification code",
33+
text: `Your verification code is: ${code}`,
34+
html: `Your verification code is: <b>${code}</b>`,
35+
});
36+
37+
return { verification };
38+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { RuntimeError, ScriptContext } from "../module.gen.ts";
2+
import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts";
3+
4+
export interface Request {
5+
email: string;
6+
password: string;
7+
}
8+
9+
export interface Response {
10+
userToken: string;
11+
userId: string;
12+
}
13+
14+
export async function run(
15+
ctx: ScriptContext,
16+
req: Request,
17+
): Promise<Response> {
18+
await ctx.modules.rateLimit.throttlePublic({});
19+
if (!ctx.config.enable.withPassword) {
20+
throw new RuntimeError("provider_disabled");
21+
}
22+
23+
// Try signing in with the email
24+
const { userToken, userId } = await ctx.modules.identities.signIn({
25+
info: IDENTITY_INFO_PASSWORD,
26+
uniqueData: {
27+
identifier: req.email,
28+
},
29+
});
30+
31+
// Verify the password
32+
await ctx.modules.userPasswords.verify({
33+
userId,
34+
password: req.password,
35+
});
36+
37+
return { userToken, userId };
38+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { RuntimeError, ScriptContext } from "../module.gen.ts";
2+
import { verifyCode } from "../utils/code_management.ts";
3+
import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts";
4+
import { ensureNotAssociatedAll } from "../utils/link_assertions.ts";
5+
6+
export interface Request {
7+
email: string;
8+
password: string;
9+
10+
verificationToken: string;
11+
code: string;
12+
}
13+
14+
export interface Response {
15+
userToken: string;
16+
}
17+
18+
export async function run(
19+
ctx: ScriptContext,
20+
req: Request,
21+
): Promise<Response> {
22+
await ctx.modules.rateLimit.throttlePublic({});
23+
if (!ctx.config.enable.withPassword) {
24+
throw new RuntimeError("provider_disabled");
25+
}
26+
27+
// Check the verification code. If it is valid, but for the wrong email, say
28+
// the verification failed.
29+
const { email } = await verifyCode(ctx, req.verificationToken, req.code);
30+
if (!compareConstantTime(req.email, email)) {
31+
throw new RuntimeError("verification_failed");
32+
}
33+
34+
// Ensure that the email is not associated with ANY accounts in ANY way.
35+
await ensureNotAssociatedAll(ctx, email, new Set());
36+
37+
// Sign up the user with the passwordless email identity
38+
const { userToken, userId } = await ctx.modules.identities.signUp({
39+
info: IDENTITY_INFO_PASSWORD,
40+
uniqueData: {
41+
identifier: email,
42+
},
43+
additionalData: {},
44+
});
45+
46+
await ctx.modules.userPasswords.add({ userId, password: req.password });
47+
48+
return { userToken };
49+
}
50+
51+
function compareConstantTime(aConstant: string, b: string) {
52+
let isEq = 1;
53+
for (let i = 0; i < aConstant.length; i++) {
54+
isEq &= Number(aConstant[i] === b[i]);
55+
}
56+
isEq &= Number(aConstant.length === b.length);
57+
58+
return Boolean(isEq);
59+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts";
2+
import { verifyCode } from "../utils/code_management.ts";
3+
import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts";
4+
import { ensureNotAssociatedAll } from "../utils/link_assertions.ts";
5+
6+
export interface Request {
7+
userToken: string;
8+
9+
email: string;
10+
password: string;
11+
oldPassword: string | null;
12+
13+
verificationToken: string;
14+
code: string;
15+
}
16+
17+
export type Response = Empty;
18+
19+
export async function run(
20+
ctx: ScriptContext,
21+
req: Request,
22+
): Promise<Response> {
23+
await ctx.modules.rateLimit.throttlePublic({});
24+
if (!ctx.config.enable.withPassword) {
25+
throw new RuntimeError("provider_disabled");
26+
}
27+
28+
// Check the verification code. If it is valid, but for the wrong email, say
29+
// the verification failed.
30+
const { email } = await verifyCode(ctx, req.verificationToken, req.code);
31+
if (!compareConstantTime(req.email, email)) {
32+
throw new RuntimeError("verification_failed");
33+
}
34+
35+
// Ensure that the email is not associated with ANY accounts in ANY way.
36+
const providedUser = await ctx.modules.users.authenticateToken({
37+
userToken: req.userToken,
38+
});
39+
await ensureNotAssociatedAll(ctx, email, new Set([providedUser.userId]));
40+
41+
// If an old password was provided, ensure it was correct and update it.
42+
// If one was not, register the user with the `userPasswords` module.
43+
if (req.oldPassword) {
44+
await ctx.modules.userPasswords.verify({
45+
userId: providedUser.userId,
46+
password: req.oldPassword,
47+
});
48+
await ctx.modules.userPasswords.update({
49+
userId: providedUser.userId,
50+
newPassword: req.password,
51+
});
52+
} else {
53+
await ctx.modules.userPasswords.add({
54+
userId: providedUser.userId,
55+
password: req.password,
56+
});
57+
}
58+
59+
// Sign up the user with the passwordless email identity
60+
await ctx.modules.identities.link({
61+
userToken: req.userToken,
62+
info: IDENTITY_INFO_PASSWORD,
63+
uniqueData: {
64+
identifier: email,
65+
},
66+
additionalData: {},
67+
});
68+
69+
return {};
70+
}
71+
72+
function compareConstantTime(aConstant: string, b: string) {
73+
let isEq = 1;
74+
for (let i = 0; i < aConstant.length; i++) {
75+
isEq &= Number(aConstant[i] === b[i]);
76+
}
77+
isEq &= Number(aConstant.length === b.length);
78+
79+
return Boolean(isEq);
80+
}

0 commit comments

Comments
 (0)