From 7a04e0ae468fec8b5ceeb35c46f3ac08c3dc4d34 Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Sat, 29 Jun 2024 19:19:09 -0400 Subject: [PATCH] feat: Create the `auth_provider` module --- .../20240803225215_setup/migration.sql | 16 ++++ .../db/migrations/migration_lock.toml | 3 + modules/identities/db/schema.prisma | 38 +++++++++ modules/identities/module.json | 64 +++++++++++++++ modules/identities/scripts/get.ts | 38 +++++++++ modules/identities/scripts/link.ts | 43 +++++++++++ modules/identities/scripts/list.ts | 23 ++++++ modules/identities/scripts/set.ts | 44 +++++++++++ modules/identities/scripts/sign_in.ts | 42 ++++++++++ .../identities/scripts/sign_in_or_sign_up.ts | 56 ++++++++++++++ modules/identities/scripts/sign_up.ts | 49 ++++++++++++ modules/identities/tests/data.ts | 66 ++++++++++++++++ modules/identities/tests/link.ts | 75 ++++++++++++++++++ modules/identities/tests/sign_up.ts | 77 +++++++++++++++++++ modules/identities/utils/db.ts | 63 +++++++++++++++ modules/identities/utils/types.ts | 9 +++ tests/basic/backend.json | 3 + 17 files changed, 709 insertions(+) create mode 100644 modules/identities/db/migrations/20240803225215_setup/migration.sql create mode 100644 modules/identities/db/migrations/migration_lock.toml create mode 100644 modules/identities/db/schema.prisma create mode 100644 modules/identities/module.json create mode 100644 modules/identities/scripts/get.ts create mode 100644 modules/identities/scripts/link.ts create mode 100644 modules/identities/scripts/list.ts create mode 100644 modules/identities/scripts/set.ts create mode 100644 modules/identities/scripts/sign_in.ts create mode 100644 modules/identities/scripts/sign_in_or_sign_up.ts create mode 100644 modules/identities/scripts/sign_up.ts create mode 100644 modules/identities/tests/data.ts create mode 100644 modules/identities/tests/link.ts create mode 100644 modules/identities/tests/sign_up.ts create mode 100644 modules/identities/utils/db.ts create mode 100644 modules/identities/utils/types.ts diff --git a/modules/identities/db/migrations/20240803225215_setup/migration.sql b/modules/identities/db/migrations/20240803225215_setup/migration.sql new file mode 100644 index 00000000..36450db5 --- /dev/null +++ b/modules/identities/db/migrations/20240803225215_setup/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "UserIdentities" ( + "userId" UUID NOT NULL, + "identityType" TEXT NOT NULL, + "identityId" TEXT NOT NULL, + "uniqueData" JSONB NOT NULL, + "additionalData" JSONB NOT NULL, + + CONSTRAINT "UserIdentities_pkey" PRIMARY KEY ("userId","identityType","identityId") +); + +-- CreateIndex +CREATE INDEX "UserIdentities_userId_idx" ON "UserIdentities"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserIdentities_identityType_identityId_uniqueData_key" ON "UserIdentities"("identityType", "identityId", "uniqueData"); diff --git a/modules/identities/db/migrations/migration_lock.toml b/modules/identities/db/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/modules/identities/db/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/modules/identities/db/schema.prisma b/modules/identities/db/schema.prisma new file mode 100644 index 00000000..07ace38e --- /dev/null +++ b/modules/identities/db/schema.prisma @@ -0,0 +1,38 @@ +// Do not modify this `datasource` block +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model UserIdentities { + userId String @db.Uuid + + // Overarching identity type: email, sms, oauth, etc. + identityType String + + // Specific identity type + // email: + // - passwordless + // - password + // - etc. + // oauth: + // - google + // - facebook + // - etc. + identityId String + + // The data that is unique to this identity. + // In the case of username, this would be the username. + // In the case of email, this would be the email address. + // In the case of oauth, this would be the oauth identity's "sub" field. + uniqueData Json + + // Additional data that is stored with the identity. + // This can be used to store things like oauth tokens, last login time, etc. + // Data here only needs to be handled by the specific identity provider. + additionalData Json + + @@index([userId]) + @@id([userId, identityType, identityId]) + @@unique([identityType, identityId, uniqueData]) +} diff --git a/modules/identities/module.json b/modules/identities/module.json new file mode 100644 index 00000000..b58b59ca --- /dev/null +++ b/modules/identities/module.json @@ -0,0 +1,64 @@ +{ + "name": "Identities", + "description": "Manage identities and identity data for users. Intended for internal use by modules exposing auth providers.", + "icon": "key", + "tags": [ + "core", + "user", + "auth" + ], + "authors": [ + "rivet-gg", + "Blckbrry-Pi" + ], + "status": "beta", + "dependencies": { + "rate_limit": {}, + "users": {}, + "tokens": {} + }, + "scripts": { + "list": { + "name": "List Identities", + "description": "List all identities the user is associated with.", + "public": true + }, + "get": { + "name": "Get Identity Data", + "description": "Get the data associated with a specific identity for a user." + }, + "set": { + "name": "Set Identity Data", + "description": "Set the data associated with a specific identity for a user." + }, + + "sign_in": { + "name": "Sign In With Identity", + "description": "Sign in to a user with an identity." + }, + "sign_up": { + "name": "Sign Up With Identity", + "description": "Sign up with an identity. Creates a new user." + }, + "sign_in_or_sign_up": { + "name": "Sign In or Sign Up With Identity", + "description": "Sign in to a user with an identity, creating a new user if it fails." + }, + "link": { + "name": "Link Identity To User", + "description": "Link a new identity and its associated data to a user. This is used for login and non-login identities." + } + }, + "routes": {}, + "errors": { + "identity_provider_not_found": { + "name": "Identity Provider Not Found" + }, + "identity_provider_already_added": { + "name": "Identity Provider Already Added To User" + }, + "identity_provider_already_used": { + "name": "Identity Provider Already Used By Other User" + } + } +} \ No newline at end of file diff --git a/modules/identities/scripts/get.ts b/modules/identities/scripts/get.ts new file mode 100644 index 00000000..5d2f3510 --- /dev/null +++ b/modules/identities/scripts/get.ts @@ -0,0 +1,38 @@ +import { ScriptContext } from "../module.gen.ts"; +import { getData } from "../utils/db.ts"; +import { IdentityData, IdentityProviderInfo } from "../utils/types.ts"; + +export interface Request { + userToken: string; + info: IdentityProviderInfo; +} + +export interface Response { + data: { + uniqueData: IdentityData; + additionalData: IdentityData; + } | null; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Ensure the user token is valid and get the user ID + const { userId } = await ctx.modules.users.authenticateToken({ userToken: req.userToken }); + + // Get identity data + const identity = await getData(ctx.db, userId, req.info.identityType, req.info.identityId); + if (!identity) return { data: null }; + + // Ensure data is of correct type + const { uniqueData, additionalData } = identity; + if (typeof uniqueData !== 'object' || Array.isArray(uniqueData) || uniqueData === null) { + return { data: null }; + } + if (typeof additionalData !== 'object' || Array.isArray(additionalData) || additionalData === null) { + return { data: null }; + } + + return { data: { uniqueData, additionalData } }; +} diff --git a/modules/identities/scripts/link.ts b/modules/identities/scripts/link.ts new file mode 100644 index 00000000..72680b5e --- /dev/null +++ b/modules/identities/scripts/link.ts @@ -0,0 +1,43 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { getData, listIdentities } from "../utils/db.ts"; +import { IdentityDataInput, IdentityProviderInfo } from "../utils/types.ts"; + +export interface Request { + userToken: string; + info: IdentityProviderInfo; + uniqueData: IdentityDataInput; + additionalData: IdentityDataInput; +} + +export interface Response { + identityProviders: IdentityProviderInfo[]; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + + // Ensure the user token is valid and get the user ID + const { userId } = await ctx.modules.users.authenticateToken({ userToken: req.userToken } ); + + return await ctx.db.$transaction(async tx => { + // Error if this identity provider is ALREADY associated with the user + if (await getData(tx, userId, req.info.identityType, req.info.identityId)) { + throw new RuntimeError("identity_provider_already_added"); + } + + // Associate the identity provider data with the user + await tx.userIdentities.create({ + data: { + userId, + identityType: req.info.identityType, + identityId: req.info.identityId, + uniqueData: req.uniqueData, + additionalData: req.additionalData, + }, + }); + + return { identityProviders: await listIdentities(tx, userId) }; + }); +} diff --git a/modules/identities/scripts/list.ts b/modules/identities/scripts/list.ts new file mode 100644 index 00000000..cf6c4227 --- /dev/null +++ b/modules/identities/scripts/list.ts @@ -0,0 +1,23 @@ +import { ScriptContext } from "../module.gen.ts"; +import { listIdentities } from "../utils/db.ts"; +import { IdentityProviderInfo } from "../utils/types.ts"; + +export interface Request { + userToken: string; +} + +export interface Response { + identityProviders: IdentityProviderInfo[]; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + + // Ensure the user token is valid and get the user ID + const { userId } = await ctx.modules.users.authenticateToken({ userToken: req.userToken } ); + + return { identityProviders: await listIdentities(ctx.db, userId) }; +} diff --git a/modules/identities/scripts/set.ts b/modules/identities/scripts/set.ts new file mode 100644 index 00000000..5e37abeb --- /dev/null +++ b/modules/identities/scripts/set.ts @@ -0,0 +1,44 @@ +import { ScriptContext, Empty, RuntimeError } from "../module.gen.ts"; +import { getData } from "../utils/db.ts"; +import { IdentityDataInput, IdentityProviderInfo } from "../utils/types.ts"; + +export interface Request { + userToken: string; + info: IdentityProviderInfo; + uniqueData?: IdentityDataInput; + additionalData: IdentityDataInput; +} + +export type Response = Empty; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Ensure the user token is valid and get the user ID + const { userId } = await ctx.modules.users.authenticateToken({ userToken: req.userToken } ); + + await ctx.db.$transaction(async tx => { + // Ensure the identity provider is associated with the user + if (!await getData(tx, userId, req.info.identityType, req.info.identityId)) { + throw new RuntimeError("identity_provider_not_found"); + } + + // Update the associated data + await tx.userIdentities.update({ + where: { + userId_identityType_identityId: { + userId, + identityType: req.info.identityType, + identityId: req.info.identityId, + } + }, + data: { + uniqueData: req.uniqueData, + additionalData: req.additionalData, + }, + }); + }); + + return {}; +} diff --git a/modules/identities/scripts/sign_in.ts b/modules/identities/scripts/sign_in.ts new file mode 100644 index 00000000..f09226e7 --- /dev/null +++ b/modules/identities/scripts/sign_in.ts @@ -0,0 +1,42 @@ +import { ScriptContext } from "../module.gen.ts"; +import { IdentityDataInput, IdentityProviderInfo } from "../utils/types.ts"; + +export interface Request { + info: IdentityProviderInfo; + uniqueData: IdentityDataInput; +} + +export interface Response { + userToken: string; + userId: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Get user the provider is associated with + const identity = await ctx.db.userIdentities.findFirst({ + where: { + identityType: req.info.identityType, + identityId: req.info.identityId, + uniqueData: { equals: req.uniqueData }, + }, + select: { + userId: true, + }, + }); + + // If the provider info/uniqueData combo is not associated with a user, + // throw provider_not_found error. + if (!identity) { + throw new Error("identity_not_found"); + } + + // Generate a user token + const { token: { token } } = await ctx.modules.users.createToken({ userId: identity.userId }); + return { + userToken: token, + userId: identity.userId, + }; +} diff --git a/modules/identities/scripts/sign_in_or_sign_up.ts b/modules/identities/scripts/sign_in_or_sign_up.ts new file mode 100644 index 00000000..1fd47753 --- /dev/null +++ b/modules/identities/scripts/sign_in_or_sign_up.ts @@ -0,0 +1,56 @@ +import { ScriptContext } from "../module.gen.ts"; +import { getUserId } from "../utils/db.ts"; +import { IdentityDataInput, IdentityProviderInfo } from "../utils/types.ts"; + +export interface Request { + info: IdentityProviderInfo; + uniqueData: IdentityDataInput; + additionalData: IdentityDataInput; + + username?: string; +} + +export interface Response { + userToken: string; + userId: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + return await ctx.db.$transaction(async tx => { + const userId = await getUserId(tx, req.info.identityType, req.info.identityId, req.uniqueData); + + // If the identity provider is associated with a user, sign in + if (userId) { + // Generate a user token + const { token: { token } } = await ctx.modules.users.createToken({ userId }); + return { + userToken: token, + userId, + }; + } else { + // Otherwise, create a new user + const { user } = await ctx.modules.users.create({ username: req.username }); + + // Insert the identity data with the newly-created user + await tx.userIdentities.create({ + data: { + userId: user.id, + identityType: req.info.identityType, + identityId: req.info.identityId, + uniqueData: req.uniqueData, + additionalData: req.additionalData, + }, + }); + + // Generate a user token and return it + const { token: { token } } = await ctx.modules.users.createToken({ userId: user.id }); + return { + userToken: token, + userId: user.id, + }; + } + }); +} diff --git a/modules/identities/scripts/sign_up.ts b/modules/identities/scripts/sign_up.ts new file mode 100644 index 00000000..ee5ea8bf --- /dev/null +++ b/modules/identities/scripts/sign_up.ts @@ -0,0 +1,49 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { getUserId } from "../utils/db.ts"; +import { IdentityDataInput, IdentityProviderInfo } from "../utils/types.ts"; + +export interface Request { + info: IdentityProviderInfo; + uniqueData: IdentityDataInput; + additionalData: IdentityDataInput; + + username?: string; +} + +export interface Response { + userToken: string; + userId: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + return await ctx.db.$transaction(async tx => { + // If the identity provider is associated with a user, throw an error + if (await getUserId(tx, req.info.identityType, req.info.identityId, req.uniqueData)) { + throw new RuntimeError("identity_provider_already_used"); + } + + // Create a new user + const { user } = await ctx.modules.users.create({ username: req.username }); + + // Insert the identity data with the newly-created user + await tx.userIdentities.create({ + data: { + userId: user.id, + identityType: req.info.identityType, + identityId: req.info.identityId, + uniqueData: req.uniqueData, + additionalData: req.additionalData, + }, + }); + + // Generate a user token and return it + const { token: { token } } = await ctx.modules.users.createToken({ userId: user.id }); + return { + userToken: token, + userId: user.id, + }; + }); +} diff --git a/modules/identities/tests/data.ts b/modules/identities/tests/data.ts new file mode 100644 index 00000000..5b6b76f5 --- /dev/null +++ b/modules/identities/tests/data.ts @@ -0,0 +1,66 @@ +import { test, TestContext } from "../module.gen.ts"; +import { assertExists, assertEquals } from "https://deno.land/std@0.217.0/assert/mod.ts"; +import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; +import { signInWithTest, signUpWithTest } from "./sign_up.ts"; + +test("Test Data Modification", async (ctx: TestContext) => { + const username = faker.internet.userName(); + const uniqueData = { unique: faker.random.alphaNumeric(10) }; + const additionalData = { additional: faker.random.alphaNumeric(100) }; + + const signUpRes = await signUpWithTest( + ctx, + username, + uniqueData, + additionalData, + ); + + // Validate the identity data + { + const { data } = await ctx.modules.identities.get({ + userToken: signUpRes.userToken, + info: { + identityType: "test", + identityId: "test", + }, + }); + assertExists(data); + assertEquals(data.uniqueData, uniqueData); + assertEquals(data.additionalData, additionalData); + } + + // Sign in with the same identity and verify that the user is the same + const signInRes = await signInWithTest(ctx, uniqueData); + assertEquals(signUpRes.userId, signInRes.userId); + assertEquals(signUpRes.user, signInRes.user); + + + // Modify the identity data + const newUniqueData = { unique: faker.random.alphaNumeric(10) }; + const newAdditionalData = { additional: faker.random.alphaNumeric(100) }; + + await ctx.modules.identities.set({ + userToken: signUpRes.userToken, + info: { + identityType: "test", + identityId: "test", + }, + uniqueData: newUniqueData, + additionalData: newAdditionalData, + }); + + + // Validate the identity data + { + const { data } = await ctx.modules.identities.get({ + userToken: signUpRes.userToken, + info: { + identityType: "test", + identityId: "test", + }, + }); + assertExists(data); + assertEquals(data.uniqueData, newUniqueData); + assertEquals(data.additionalData, newAdditionalData); + } +}); diff --git a/modules/identities/tests/link.ts b/modules/identities/tests/link.ts new file mode 100644 index 00000000..016ec69c --- /dev/null +++ b/modules/identities/tests/link.ts @@ -0,0 +1,75 @@ +import { test, TestContext } from "../module.gen.ts"; +import { assertExists, assertEquals } from "https://deno.land/std@0.217.0/assert/mod.ts"; +import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; +import { signInWithTest, signUpWithTest } from "./sign_up.ts"; + +test("Link a new identity to an existing user", async (ctx: TestContext) => { + const username = faker.internet.userName(); + + const uniqueData = { unique: faker.random.alphaNumeric(10) }; + const additionalData = { additional: faker.random.alphaNumeric(100) }; + + const signUpRes = await signUpWithTest( + ctx, + username, + uniqueData, + additionalData, + ); + + // Validate the identity data + { + const { data } = await ctx.modules.identities.get({ + userToken: signUpRes.userToken, + info: { + identityType: "test", + identityId: "test", + }, + }); + assertExists(data); + assertEquals(data.uniqueData, uniqueData); + assertEquals(data.additionalData, additionalData); + } + + // Sign in with the same identity and verify that the user is the same + const signInRes = await signInWithTest(ctx, uniqueData); + assertEquals(signUpRes.userId, signInRes.userId); + assertEquals(signUpRes.user, signInRes.user); + + + // Link a new identity to the existing user + const newUniqueData = { unique: faker.random.alphaNumeric(10) }; + const newAdditionalData = { additional: faker.random.alphaNumeric(100) }; + await ctx.modules.identities.link({ + userToken: signUpRes.userToken, + info: { + identityType: "test2", + identityId: "test2", + }, + uniqueData: newUniqueData, + additionalData: newAdditionalData, + }); + + // Validate the new identity data + { + const { data } = await ctx.modules.identities.get({ + userToken: signUpRes.userToken, + info: { + identityType: "test2", + identityId: "test2", + }, + }); + assertExists(data); + assertEquals(data.uniqueData, newUniqueData); + assertEquals(data.additionalData, newAdditionalData); + } + + // List all identity providers and verify that both are present and both match + const { identityProviders } = await ctx.modules.identities.list({ userToken: signUpRes.userToken }); + + assertEquals(identityProviders.length, 2); + const [first, second] = identityProviders; + const [test, test2] = [first, second].sort((a, b) => a.identityType.localeCompare(b.identityType)); + + assertEquals(test, { identityId: "test", identityType: "test" }); + assertEquals(test2, { identityId: "test2", identityType: "test2" }); +}); diff --git a/modules/identities/tests/sign_up.ts b/modules/identities/tests/sign_up.ts new file mode 100644 index 00000000..d8a05f43 --- /dev/null +++ b/modules/identities/tests/sign_up.ts @@ -0,0 +1,77 @@ +import { test, TestContext } from "../module.gen.ts"; +import { assertExists, assertEquals } from "https://deno.land/std@0.217.0/assert/mod.ts"; +import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; + +// deno-lint-ignore no-explicit-any +export async function signUpWithTest(ctx: TestContext, username: string, uniqueData: any, additionalData: any) { + const { userToken } = await ctx.modules.identities.signUp({ + info: { + identityType: "test", + identityId: "test", + }, + username, + uniqueData, + additionalData, + }); + + const { userId, user } = await ctx.modules.users.authenticateToken({ + userToken, + fetchUser: true, + }); + assertExists(user); + assertEquals(user.username, username); + + return { userToken, userId, user }; +} + +// deno-lint-ignore no-explicit-any +export async function signInWithTest(ctx: TestContext, uniqueData: any) { + const { userToken } = await ctx.modules.identities.signIn({ + info: { + identityType: "test", + identityId: "test", + }, + uniqueData, + }); + + const { userId, user } = await ctx.modules.users.authenticateToken({ + userToken, + fetchUser: true, + }); + assertExists(user); + + return { userToken, userId, user }; +} + +test("Sign Up and Sign In", async (ctx: TestContext) => { + const username = faker.internet.userName(); + + const uniqueData = { unique: faker.random.alphaNumeric(10) }; + const additionalData = { additional: faker.random.alphaNumeric(100) }; + + const signUpRes = await signUpWithTest( + ctx, + username, + uniqueData, + additionalData, + ); + + // Validate the identity data + { + const { data } = await ctx.modules.identities.get({ + userToken: signUpRes.userToken, + info: { + identityType: "test", + identityId: "test", + }, + }); + assertExists(data); + assertEquals(data.uniqueData, uniqueData); + assertEquals(data.additionalData, additionalData); + } + + // Sign in with the same identity and verify that the user is the same + const signInRes = await signInWithTest(ctx, uniqueData); + assertEquals(signUpRes.userId, signInRes.userId); + assertEquals(signUpRes.user, signInRes.user); +}); diff --git a/modules/identities/utils/db.ts b/modules/identities/utils/db.ts new file mode 100644 index 00000000..081cff8e --- /dev/null +++ b/modules/identities/utils/db.ts @@ -0,0 +1,63 @@ +import { prisma } from "../module.gen.ts"; + +import { IdentityDataInput } from "./types.ts"; + + +type DataDb = { userIdentities: prisma.PrismaClient["userIdentities"] }; + +export async function getUserId( + db: DataDb, + identityType: string, + identityId: string, + uniqueData: IdentityDataInput, +) { + const identity = await db.userIdentities.findFirst({ + where: { + identityType, + identityId, + uniqueData: { equals: uniqueData }, + }, + select: { + userId: true, + }, + }); + + return identity?.userId; +} + +export async function getData( + db: DataDb, + userId: string, + identityType: string, + identityId: string, +) { + const identity = await db.userIdentities.findFirst({ + where: { + userId, + identityType, + identityId, + }, + select: { + uniqueData: true, + additionalData: true, + }, + }); + + return identity; +} + + +export async function listIdentities( + db: DataDb, + userId: string, +) { + return await db.userIdentities.findMany({ + where: { + userId, + }, + select: { + identityType: true, + identityId: true, + } + }); +} diff --git a/modules/identities/utils/types.ts b/modules/identities/utils/types.ts new file mode 100644 index 00000000..b91b32a8 --- /dev/null +++ b/modules/identities/utils/types.ts @@ -0,0 +1,9 @@ +import { prisma } from "../module.gen.ts"; + +export type IdentityData = Record; +export type IdentityDataInput = IdentityData & prisma.Prisma.InputJsonValue; + +export interface IdentityProviderInfo { + identityType: string; + identityId: string; +} diff --git a/tests/basic/backend.json b/tests/basic/backend.json index 897a398e..54898cf3 100644 --- a/tests/basic/backend.json +++ b/tests/basic/backend.json @@ -38,6 +38,9 @@ } } }, + "identities": { + "registry": "local" + }, "email": { "registry": "local", "config": {