diff --git a/modules/identities/db/migrations/20240703025745_/migration.sql b/modules/identities/db/migrations/20240703025745_/migration.sql new file mode 100644 index 00000000..b56987b6 --- /dev/null +++ b/modules/identities/db/migrations/20240703025745_/migration.sql @@ -0,0 +1,22 @@ +-- 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 INDEX "UserIdentities_identityType_identityId_idx" ON "UserIdentities"("identityType", "identityId"); + +-- CreateIndex +CREATE INDEX "UserIdentities_identityType_identityId_uniqueData_idx" ON "UserIdentities"("identityType", "identityId", "uniqueData"); + +-- 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..aaa51b8a --- /dev/null +++ b/modules/identities/db/schema.prisma @@ -0,0 +1,27 @@ +// Do not modify this `datasource` block +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model UserIdentities { + userId String @db.Uuid + + // Used to identify the user from the identity + identityType String + identityId String + uniqueData Json + + additionalData Json + + // Additional indexes for speed + @@index([userId]) + @@index([identityType, identityId]) + + // Each user should only have one identity per (type, id) pair (for now) + @@id([userId, identityType, identityId]) + + // Each identity should only be linked to one user + @@index([identityType, identityId, uniqueData]) + @@unique([identityType, identityId, uniqueData]) +} diff --git a/modules/identities/module.json b/modules/identities/module.json new file mode 100644 index 00000000..056362bd --- /dev/null +++ b/modules/identities/module.json @@ -0,0 +1,64 @@ +{ + "name": "Identites", + "description": "Manage identities and identity data for users.", + "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 identites." + } + }, + "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..2013fdc5 --- /dev/null +++ b/modules/identities/scripts/get.ts @@ -0,0 +1,58 @@ +import { ScriptContext } from "../module.gen.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 { + await ctx.modules.rateLimit.throttle({ + key: req.userToken, + period: 10, + requests: 10, + type: "user", + }); + + // 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 ctx.db.userIdentities.findFirst({ + where: { + userId, + identityType: req.info.identityType, + identityId: req.info.identityId, + }, + select: { + uniqueData: true, + additionalData: true + } + }); + + // Type checking to make typescript happy + const data = identity ?? null; + if (!data) { + return { data: null }; + } + + const { uniqueData, additionalData } = data; + 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..025a0c2c --- /dev/null +++ b/modules/identities/scripts/link.ts @@ -0,0 +1,45 @@ +import { RuntimeError, ScriptContext } from "../module.gen.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 { + await ctx.modules.rateLimit.throttle({ + key: req.userToken, + period: 10, + requests: 10, + type: "user", + }); + + // Ensure the user token is valid and get the user ID + const { userId } = await ctx.modules.users.authenticateToken({ userToken: req.userToken } ); + + // Error if this identity provider is ALREADY associated with the user + const { data: prevData } = await ctx.modules.identities.get({ userToken: req.userToken, info: req.info }); + if (prevData) throw new RuntimeError("identity_provider_already_added"); + + // Add a new entry to the table with the associated data + await ctx.db.userIdentities.create({ + data: { + userId, + identityType: req.info.identityType, + identityId: req.info.identityId, + uniqueData: req.uniqueData, + additionalData: req.additionalData, + }, + }); + + return await ctx.modules.identities.list({ userToken: req.userToken }); +} diff --git a/modules/identities/scripts/list.ts b/modules/identities/scripts/list.ts new file mode 100644 index 00000000..3142829a --- /dev/null +++ b/modules/identities/scripts/list.ts @@ -0,0 +1,33 @@ +import { ScriptContext } from "../module.gen.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 } ); + + // Select identityType and identityId entries that match the userId + const identityProviders = await ctx.db.userIdentities.findMany({ + where: { + userId, + }, + select: { + identityType: true, + identityId: true, + } + }); + + return { identityProviders }; +} diff --git a/modules/identities/scripts/set.ts b/modules/identities/scripts/set.ts new file mode 100644 index 00000000..0c53a49d --- /dev/null +++ b/modules/identities/scripts/set.ts @@ -0,0 +1,48 @@ +import { ScriptContext, Empty, RuntimeError } from "../module.gen.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 { + await ctx.modules.rateLimit.throttle({ + key: req.userToken, + period: 10, + requests: 10, + type: "user", + }); + + // Ensure the user token is valid and get the user ID + const { userId } = await ctx.modules.users.authenticateToken({ userToken: req.userToken } ); + + // Error if this identity provider is not associated with the user + const { data: prevData } = await ctx.modules.identities.get({ userToken: req.userToken, info: req.info }); + if (!prevData) throw new RuntimeError("identity_provider_not_found"); + + + // Update the identity data where userId, identityType, and identityId match + await ctx.db.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..53cd14f1 --- /dev/null +++ b/modules/identities/scripts/sign_in.ts @@ -0,0 +1,46 @@ +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; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const key = req.info.identityType + ":" + req.info.identityId + ":" + JSON.stringify(req.uniqueData); + await ctx.modules.rateLimit.throttle({ + key, + period: 10, + requests: 10, + type: "user", + }); + + // Get users 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 }; +} 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..eba07c09 --- /dev/null +++ b/modules/identities/scripts/sign_in_or_sign_up.ts @@ -0,0 +1,38 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { IdentityDataInput, IdentityProviderInfo } from "../utils/types.ts"; + +export interface Request { + info: IdentityProviderInfo; + uniqueData: IdentityDataInput; + additionalData: IdentityDataInput; + + username?: string; +} + +export interface Response { + userToken: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + try { + return await ctx.modules.identities.signIn({ + info: req.info, + uniqueData: req.uniqueData, + }); + } catch (e) { + if (e instanceof RuntimeError) { + if (e.code === "identity_not_found") { + return await ctx.modules.identities.signUp({ + info: req.info, + uniqueData: req.uniqueData, + additionalData: req.additionalData, + username: req.username, + }); + } + } + throw e; + } +} diff --git a/modules/identities/scripts/sign_up.ts b/modules/identities/scripts/sign_up.ts new file mode 100644 index 00000000..382be459 --- /dev/null +++ b/modules/identities/scripts/sign_up.ts @@ -0,0 +1,63 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { IdentityDataInput, IdentityProviderInfo } from "../utils/types.ts"; + +export interface Request { + info: IdentityProviderInfo; + uniqueData: IdentityDataInput; + additionalData: IdentityDataInput; + + username?: string; +} + +export interface Response { + userToken: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const key = req.info.identityType + ":" + req.info.identityId + ":" + JSON.stringify(req.uniqueData); + await ctx.modules.rateLimit.throttle({ + key, + period: 10, + requests: 10, + type: "user", + }); + + // Get users 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 identity provider is associated with a user, throw an error + if (identity) { + 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 ctx.db.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 }; +} 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/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": {