diff --git a/modules/auth_oauth2/db/migrations/20240701012627_init/migration.sql b/modules/auth_oauth2/db/migrations/20240701041159_init/migration.sql similarity index 94% rename from modules/auth_oauth2/db/migrations/20240701012627_init/migration.sql rename to modules/auth_oauth2/db/migrations/20240701041159_init/migration.sql index 4030d48b..be1b8067 100644 --- a/modules/auth_oauth2/db/migrations/20240701012627_init/migration.sql +++ b/modules/auth_oauth2/db/migrations/20240701041159_init/migration.sql @@ -5,6 +5,7 @@ CREATE TABLE "LoginAttempts" ( "state" TEXT NOT NULL, "codeVerifier" TEXT NOT NULL, "identifier" TEXT, + "tokenData" JSONB, "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "expiresAt" TIMESTAMP(3) NOT NULL, "completedAt" TIMESTAMP(3), diff --git a/modules/auth_oauth2/db/schema.prisma b/modules/auth_oauth2/db/schema.prisma index eb22ff26..b3a431c2 100644 --- a/modules/auth_oauth2/db/schema.prisma +++ b/modules/auth_oauth2/db/schema.prisma @@ -12,6 +12,7 @@ model LoginAttempts { codeVerifier String identifier String? + tokenData Json? startedAt DateTime @default(now()) expiresAt DateTime diff --git a/modules/auth_oauth2/module.json b/modules/auth_oauth2/module.json index 174b6586..16165dbd 100644 --- a/modules/auth_oauth2/module.json +++ b/modules/auth_oauth2/module.json @@ -36,6 +36,16 @@ "name": "Get Status", "description": "Check the status of a OAuth login using the flow token. Returns the status of the login flow.", "public": true + }, + "add_to_user": { + "name": "Add OAuth Login to User", + "description": "Use a finished OAuth flow to add the OAuth login to an already-authenticated users.", + "public": true + }, + "login_to_user": { + "name": "Login to or Create User with OAuth", + "description": "Use a finished OAuth flow to login to a user, creating a new one if it doesn't exist.", + "public": true } }, "errors": { diff --git a/modules/auth_oauth2/routes/login_callback.ts b/modules/auth_oauth2/routes/login_callback.ts index 8f38424e..47427862 100644 --- a/modules/auth_oauth2/routes/login_callback.ts +++ b/modules/auth_oauth2/routes/login_callback.ts @@ -89,6 +89,7 @@ export async function handle( }, data: { identifier: ident, + tokenData: { ...tokens }, completedAt: new Date(), }, }); diff --git a/modules/auth_oauth2/scripts/add_to_user.ts b/modules/auth_oauth2/scripts/add_to_user.ts new file mode 100644 index 00000000..1cbf209d --- /dev/null +++ b/modules/auth_oauth2/scripts/add_to_user.ts @@ -0,0 +1,64 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; + +export interface Request { + flowToken: string; + userToken: string; +} + +export type Response = ReturnType; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + + if (!req.flowToken) throw new RuntimeError("missing_token", { statusCode: 400 }); + + const { tokens: [flowToken] } = await ctx.modules.tokens.fetchByToken({ tokens: [req.flowToken] }); + if (!flowToken) { + throw new RuntimeError("invalid_token", { statusCode: 400 }); + } + if (new Date(flowToken.expireAt ?? 0) < new Date()) { + throw new RuntimeError("expired_token", { statusCode: 400 }); + } + + const flowId = flowToken.meta.flowId; + if (!flowId) throw new RuntimeError("invalid_token", { statusCode: 400 }); + + const flow = await ctx.db.loginAttempts.findFirst({ + where: { + id: flowId, + } + }); + if (!flow) throw new RuntimeError("invalid_token", { statusCode: 400 }); + + if (!flow.identifier || !flow.tokenData) { + throw new RuntimeError("flow_not_complete", { statusCode: 400 }); + } + + await ctx.modules.users.authenticateToken({ userToken: req.userToken }); + + const tokenData = flow.tokenData; + if (!tokenData) { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + if (typeof tokenData !== "object") { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + if (Array.isArray(tokenData)) { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + + return await ctx.modules.authProviders.addProviderToUser({ + userToken: req.userToken, + info: { + providerType: "oauth2", + providerId: flow.providerId, + }, + uniqueData: { + identifier: flow.identifier, + }, + additionalData: tokenData, + }); +} diff --git a/modules/auth_oauth2/scripts/get_status.ts b/modules/auth_oauth2/scripts/get_status.ts index b80d18ee..29699796 100644 --- a/modules/auth_oauth2/scripts/get_status.ts +++ b/modules/auth_oauth2/scripts/get_status.ts @@ -29,7 +29,7 @@ export async function run( }); if (!flow) throw new RuntimeError("invalid_token", { statusCode: 400 }); - if (flow.identifier) { + if (flow.identifier && flow.tokenData) { return { status: "complete" }; } else if (new Date(flow.expiresAt) < new Date()) { return { status: "expired" }; diff --git a/modules/auth_oauth2/scripts/login_to_user.ts b/modules/auth_oauth2/scripts/login_to_user.ts new file mode 100644 index 00000000..8684ff6f --- /dev/null +++ b/modules/auth_oauth2/scripts/login_to_user.ts @@ -0,0 +1,60 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; + +export interface Request { + flowToken: string; +} + +export type Response = ReturnType; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + + if (!req.flowToken) throw new RuntimeError("missing_token", { statusCode: 400 }); + + const { tokens: [flowToken] } = await ctx.modules.tokens.fetchByToken({ tokens: [req.flowToken] }); + if (!flowToken) { + throw new RuntimeError("invalid_token", { statusCode: 400 }); + } + if (new Date(flowToken.expireAt ?? 0) < new Date()) { + throw new RuntimeError("expired_token", { statusCode: 400 }); + } + + const flowId = flowToken.meta.flowId; + if (!flowId) throw new RuntimeError("invalid_token", { statusCode: 400 }); + + const flow = await ctx.db.loginAttempts.findFirst({ + where: { + id: flowId, + } + }); + if (!flow) throw new RuntimeError("invalid_token", { statusCode: 400 }); + + if (!flow.identifier || !flow.tokenData) { + throw new RuntimeError("flow_not_complete", { statusCode: 400 }); + } + + const tokenData = flow.tokenData; + if (!tokenData) { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + if (typeof tokenData !== "object") { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + if (Array.isArray(tokenData)) { + throw new RuntimeError("internal_error", { statusCode: 500 }); + } + + return await ctx.modules.authProviders.getOrCreateUserFromProvider({ + info: { + providerType: "oauth2", + providerId: flow.providerId, + }, + uniqueData: { + identifier: flow.identifier, + }, + additionalData: tokenData, + }); +}