feat(api): Add secret rudimentary OIDC account linking support

This commit is contained in:
Jesse Wierzbinski 2024-05-12 18:34:35 -10:00
parent ff43b19122
commit 7f6aeeb859
No known key found for this signature in database
2 changed files with 151 additions and 4 deletions

View file

@ -2,8 +2,9 @@ import { randomBytes } from "node:crypto";
import { applyConfig, handleZodError } from "@api";
import { oauthRedirectUri } from "@constants";
import { zValidator } from "@hono/zod-validator";
import { errorResponse, response } from "@response";
import { errorResponse, jsonResponse, response } from "@response";
import type { Hono } from "hono";
import { SignJWT } from "jose";
import {
authorizationCodeGrantRequest,
discoveryRequest,
@ -19,10 +20,9 @@ import {
import { z } from "zod";
import { TokenType } from "~database/entities/Token";
import { db } from "~drizzle/db";
import { Tokens } from "~drizzle/schema";
import { OpenIdAccounts, Tokens } from "~drizzle/schema";
import { config } from "~packages/config-manager";
import { User } from "~packages/database-interface/user";
import { SignJWT } from "jose";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -40,6 +40,11 @@ export const schemas = {
query: z.object({
clientId: z.string().optional(),
flow: z.string(),
link: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
user_id: z.string().uuid().optional(),
}),
param: z.object({
issuer: z.string(),
@ -75,7 +80,7 @@ export default (app: Hono) =>
// Remove state query parameter from URL
currentUrl.searchParams.delete("state");
const { issuer: issuerParam } = context.req.valid("param");
const { flow: flowId, clientId } = context.req.valid("query");
const { flow: flowId, user_id, link } = context.req.valid("query");
const flow = await db.query.OpenIdLoginFlows.findFirst({
where: (flow, { eq }) => eq(flow.id, flowId),
@ -193,6 +198,37 @@ export default (app: Hono) =>
),
);
if (link && user_id) {
// Check if userId is equal to application.clientId
if (!flow.application?.clientId.startsWith(user_id)) {
return errorResponse("User ID does not match application");
}
// Check if account is already linked
const account = await db.query.OpenIdAccounts.findFirst({
where: (account, { eq, and }) =>
and(
eq(account.serverId, sub),
eq(account.issuerId, issuer.id),
),
});
if (account) {
return errorResponse("Account already linked");
}
// Link the account
await db.insert(OpenIdAccounts).values({
serverId: sub,
issuerId: issuer.id,
userId: user_id,
});
return response(null, 302, {
Location: config.http.base_url,
});
}
const userId = (
await db.query.OpenIdAccounts.findFirst({
where: (account, { eq, and }) =>

View file

@ -0,0 +1,111 @@
import { randomBytes } from "node:crypto";
import { applyConfig, auth, handleZodError } from "@api";
import { oauthRedirectUri } from "@constants";
import { zValidator } from "@hono/zod-validator";
import { errorResponse, jsonResponse, redirect, response } from "@response";
import type { Hono } from "hono";
import {
calculatePKCECodeChallenge,
discoveryRequest,
generateRandomCodeVerifier,
processDiscoveryResponse,
} from "oauth4webapi";
import { z } from "zod";
import { db } from "~drizzle/db";
import { Applications, OpenIdLoginFlows } from "~drizzle/schema";
import { config } from "~packages/config-manager";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: true,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/oauth/link",
});
export const schemas = {
query: z.object({
issuer: z.string(),
}),
};
export default (app: Hono) =>
app.on(
meta.allowedMethods,
meta.route,
zValidator("query", schemas.query, handleZodError),
auth(meta.auth),
async (context) => {
const { issuer: issuerId } = context.req.valid("query");
const { user } = context.req.valid("header");
if (!user) {
return errorResponse("Unauthorized", 401);
}
const issuer = config.oidc.providers.find(
(provider) => provider.id === issuerId,
);
if (!issuer) {
return errorResponse(`Issuer ${issuerId} not found`, 404);
}
const issuerUrl = new URL(issuer.url);
const authServer = await discoveryRequest(issuerUrl, {
algorithm: "oidc",
}).then((res) => processDiscoveryResponse(issuerUrl, res));
const codeVerifier = generateRandomCodeVerifier();
const application = (
await db
.insert(Applications)
.values({
clientId:
user.id + randomBytes(32).toString("base64url"),
name: "Lysand",
redirectUri: `${oauthRedirectUri(issuerId)}`,
scopes: "openid profile email",
secret: "",
})
.returning()
)[0];
// Store into database
const newFlow = (
await db
.insert(OpenIdLoginFlows)
.values({
codeVerifier,
issuerId,
applicationId: application.id,
})
.returning()
)[0];
const codeChallenge =
await calculatePKCECodeChallenge(codeVerifier);
return jsonResponse({
link: `${
authServer.authorization_endpoint
}?${new URLSearchParams({
client_id: issuer.client_id,
redirect_uri: `${oauthRedirectUri(issuerId)}?flow=${
newFlow.id
}&link=true&user_id=${user.id}`,
response_type: "code",
scope: "openid profile email",
// PKCE
code_challenge_method: "S256",
code_challenge: codeChallenge,
}).toString()}`,
});
},
);