refactor(api): ♻️ Change route names, improve API endpoints to be more consistent with Mastodon API

This commit is contained in:
Jesse Wierzbinski 2024-05-16 15:49:59 -10:00
parent a6eb826b04
commit b1216a43f2
No known key found for this signature in database
19 changed files with 785 additions and 375 deletions

View file

@ -1,4 +1,5 @@
import { Note } from "./note";
import { OAuthManager } from "./oauth";
import { Timeline } from "./timeline";
export { Note, Timeline };
export { Note, Timeline, OAuthManager };

View file

@ -0,0 +1,270 @@
import { oauthRedirectUri } from "@constants";
import { errorResponse, response } from "@response";
import type { InferInsertModel } from "drizzle-orm";
import {
type AuthorizationServer,
authorizationCodeGrantRequest,
discoveryRequest,
expectNoState,
getValidatedIdTokenClaims,
isOAuth2Error,
processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse,
processUserInfoResponse,
userInfoRequest,
validateAuthResponse,
} from "oauth4webapi";
import type { Application } from "~database/entities/Application";
import { db } from "~drizzle/db";
import { type Applications, OpenIdAccounts } from "~drizzle/schema";
import { config } from "~packages/config-manager";
export class OAuthManager {
public issuer: (typeof config.oidc.providers)[0];
constructor(public issuer_id: string) {
const found = config.oidc.providers.find(
(provider) => provider.id === this.issuer_id,
);
if (!found) {
throw new Error(`Issuer ${this.issuer_id} not found`);
}
this.issuer = found;
}
async getFlow(flowId: string) {
return await db.query.OpenIdLoginFlows.findFirst({
where: (flow, { eq }) => eq(flow.id, flowId),
with: {
application: true,
},
});
}
async getAuthServer(issuerUrl: URL) {
return await discoveryRequest(issuerUrl, {
algorithm: "oidc",
}).then((res) => processDiscoveryResponse(issuerUrl, res));
}
async getParameters(
authServer: AuthorizationServer,
issuer: (typeof config.oidc.providers)[0],
currentUrl: URL,
) {
return validateAuthResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
currentUrl,
expectNoState,
);
}
async getOIDCResponse(
authServer: AuthorizationServer,
issuer: (typeof config.oidc.providers)[0],
redirectUri: string,
codeVerifier: string,
parameters: URLSearchParams,
) {
return await authorizationCodeGrantRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
parameters,
redirectUri,
codeVerifier,
);
}
async processOIDCResponse(
authServer: AuthorizationServer,
issuer: (typeof config.oidc.providers)[0],
oidcResponse: Response,
) {
return await processAuthorizationCodeOpenIDResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
oidcResponse,
);
}
async getUserInfo(
authServer: AuthorizationServer,
issuer: (typeof config.oidc.providers)[0],
access_token: string,
sub: string,
) {
return await userInfoRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
access_token,
).then(
async (res) =>
await processUserInfoResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
sub,
res,
),
);
}
async processOAuth2Error(
application: InferInsertModel<typeof Applications> | null,
) {
return {
redirect_uri: application?.redirectUri,
client_id: application?.clientId,
response_type: "code",
scope: application?.scopes,
};
}
async linkUser(
userId: string,
// Return value of automaticOidcFlow
oidcFlowData: Exclude<
Awaited<
ReturnType<typeof OAuthManager.prototype.automaticOidcFlow>
>,
Response
>,
) {
const { flow, userInfo } = oidcFlowData;
// Check if userId is equal to application.clientId
if ((flow.application?.clientId ?? "") !== userId) {
return response(null, 302, {
Location: `${config.http.base_url}?${new URLSearchParams({
oidc_account_linking_error: "Account linking error",
oidc_account_linking_error_message: `User ID does not match application client ID (${userId} != ${flow.application?.clientId})`,
})}`,
});
}
// Check if account is already linked
const account = await db.query.OpenIdAccounts.findFirst({
where: (account, { eq, and }) =>
and(
eq(account.serverId, userInfo.sub),
eq(account.issuerId, this.issuer.id),
),
});
if (account) {
return response(null, 302, {
Location: `${config.http.base_url}?${new URLSearchParams({
oidc_account_linking_error: "Account already linked",
oidc_account_linking_error_message:
"This account has already been linked to this OpenID Connect provider.",
})}`,
});
}
// Link the account
await db.insert(OpenIdAccounts).values({
serverId: userInfo.sub,
issuerId: this.issuer.id,
userId: userId,
});
return response(null, 302, {
Location: `${config.http.base_url}?${new URLSearchParams({
oidc_account_linked: "true",
})}`,
});
}
async automaticOidcFlow(
flowId: string,
currentUrl: URL,
errorFn: (
error: string,
message: string,
app: Application | null,
) => Response,
) {
const flow = await this.getFlow(flowId);
if (!flow) {
return errorFn("invalid_request", "Invalid flow", null);
}
const issuerUrl = new URL(this.issuer.url);
const authServer = await this.getAuthServer(issuerUrl);
const parameters = await this.getParameters(
authServer,
this.issuer,
currentUrl,
);
if (isOAuth2Error(parameters)) {
return errorFn(
parameters.error,
parameters.error_description || "",
flow.application,
);
}
const oidcResponse = await this.getOIDCResponse(
authServer,
this.issuer,
`${oauthRedirectUri(this.issuer.id)}?flow=${flow.id}`,
flow.codeVerifier,
parameters,
);
const result = await this.processOIDCResponse(
authServer,
this.issuer,
oidcResponse,
);
if (isOAuth2Error(result)) {
return errorFn(
result.error,
result.error_description || "",
flow.application,
);
}
const { access_token } = result;
const claims = getValidatedIdTokenClaims(result);
const { sub } = claims;
// Validate `sub`
// Later, we'll use this to automatically set the user's data
const userInfo = await this.getUserInfo(
authServer,
this.issuer,
access_token,
sub,
);
return {
userInfo: userInfo,
flow: flow,
claims: claims,
};
}
}

View file

@ -102,7 +102,7 @@ describe(meta.route, () => {
body: new URLSearchParams({
status: "Reply",
in_reply_to_id: timeline[0].id,
federate: "false",
local_only: "true",
}),
}),
);

View file

@ -0,0 +1,21 @@
import { applyConfig } from "@api";
import { jsonResponse } from "@response";
import type { Hono } from "hono";
import { config } from "~packages/config-manager";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 120,
},
route: "/api/v1/frontend/config",
});
export default (app: Hono) =>
app.on(meta.allowedMethods, meta.route, async () => {
return jsonResponse(config.frontend.settings);
});

View file

@ -80,7 +80,7 @@ export default (app: Hono) =>
user_count: userCount,
},
thumbnail: proxyUrl(config.instance.logo),
banner: proxyUrl(config.instance.banner) ?? "",
banner: proxyUrl(config.instance.banner),
title: config.instance.name,
uri: config.http.base_url,
urls: {
@ -88,79 +88,25 @@ export default (app: Hono) =>
},
version: "4.3.0-alpha.3+glitch",
lysand_version: version,
pleroma: {
metadata: {
account_activation_required: false,
features: [
"pleroma_api",
"akkoma_api",
"mastodon_api",
// "mastodon_api_streaming",
// "polls",
// "v2_suggestions",
// "pleroma_explicit_addressing",
// "shareable_emoji_packs",
// "multifetch",
// "pleroma:api/v1/notifications:include_types_filter",
"quote_posting",
"editing",
// "bubble_timeline",
// "relay",
// "pleroma_emoji_reactions",
// "exposable_reactions",
// "profile_directory",
"custom_emoji_reactions",
// "pleroma:get:main/ostatus",
],
federation: {
enabled: true,
exclusions: false,
mrf_policies: [],
mrf_simple: {
accept: [],
avatar_removal: [],
background_removal: [],
banner_removal: [],
federated_timeline_removal: [],
followers_only: [],
media_nsfw: [],
media_removal: [],
reject: [],
reject_deletes: [],
report_removal: [],
},
mrf_simple_info: {
media_nsfw: {},
reject: {},
},
quarantined_instances: [],
quarantined_instances_info: {
quarantined_instances: {},
},
},
fields_limits: {
max_fields: config.validation.max_field_count,
max_remote_fields: 9999,
name_length: config.validation.max_field_name_size,
value_length: config.validation.max_field_value_size,
},
post_formats: [
"text/plain",
"text/html",
"text/markdown",
"text/x.misskeymarkdown",
],
privileged_staff: false,
},
stats: {
mau: monthlyActiveUsers,
},
vapid_public_key: "",
sso: {
forced: false,
providers: config.oidc.providers.map((p) => ({
name: p.name,
icon: p.icon,
id: p.id,
})),
},
contact_account: contactAccount?.toAPI() || undefined,
} satisfies APIInstance & {
banner: string;
banner: string | null;
lysand_version: string;
pleroma: object;
sso: {
forced: boolean;
providers: {
id: string;
name: string;
icon?: string;
}[];
};
});
});

View file

@ -86,7 +86,7 @@ beforeAll(async () => {
body: new URLSearchParams({
status: `@${users[0].getUser().username} test mention`,
visibility: "direct",
federate: "false",
local_only: "true",
}),
}),
);

View file

@ -0,0 +1,67 @@
import { afterAll, describe, expect, test } from "bun:test";
import { config } from "~packages/config-manager";
import {
deleteOldTestUsers,
getTestUsers,
sendTestRequest,
} from "~tests/utils";
import { meta } from "./index";
await deleteOldTestUsers();
const { deleteUsers, tokens } = await getTestUsers(1);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/sso/:id
describe(meta.route, () => {
test("should not find unknown issuer", async () => {
const response = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", "unknown"),
config.http.base_url,
),
{
method: "GET",
headers: {
Authorization: `Bearer ${tokens[0]?.accessToken}`,
},
},
),
);
expect(response.status).toBe(404);
expect(await response.json()).toMatchObject({
error: "Issuer not found",
});
const response2 = await sendTestRequest(
new Request(
new URL(
meta.route.replace(":id", "unknown"),
config.http.base_url,
),
{
method: "DELETE",
headers: {
Authorization: `Bearer ${tokens[0]?.accessToken}`,
"Content-Type": "application/json",
},
},
),
);
expect(response2.status).toBe(404);
expect(await response2.json()).toMatchObject({
error: "Issuer not found",
});
});
/*
Unfortunately, we cannot test actual linking, as it requires a valid OpenID provider
setup in config, which we don't have in tests
*/
});

View file

@ -0,0 +1,104 @@
import { applyConfig, auth, handleZodError, jsonOrForm } from "@api";
import { zValidator } from "@hono/zod-validator";
import { errorResponse, jsonResponse, response } from "@response";
import { eq } from "drizzle-orm";
import type { Hono } from "hono";
import { z } from "zod";
import { db } from "~drizzle/db";
import { OpenIdAccounts } from "~drizzle/schema";
import { config } from "~packages/config-manager";
export const meta = applyConfig({
allowedMethods: ["GET", "DELETE"],
auth: {
required: true,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/api/v1/sso/:id",
});
export const schemas = {
param: z.object({
id: z.string(),
}),
};
/**
* SSO Account Linking management endpoint
* A GET request allows the user to list all their linked accounts
* A POST request allows the user to link a new account
*/
export default (app: Hono) =>
app.on(
meta.allowedMethods,
meta.route,
zValidator("param", schemas.param, handleZodError),
auth(meta.auth),
async (context) => {
const { id: issuerId } = context.req.valid("param");
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 not found", 404);
}
switch (context.req.method) {
case "GET": {
// Get all linked accounts
const account = await db.query.OpenIdAccounts.findFirst({
where: (account, { eq, and }) =>
and(
eq(account.userId, account.id),
eq(account.issuerId, issuerId),
),
});
if (!account) {
return errorResponse(
"Account not found or is not linked to this issuer",
404,
);
}
return jsonResponse({
id: issuer.id,
name: issuer.name,
icon: issuer.icon,
});
}
case "DELETE": {
const account = await db.query.OpenIdAccounts.findFirst({
where: (account, { eq, and }) =>
and(
eq(account.userId, user.id),
eq(account.issuerId, issuerId),
),
});
if (!account) {
return errorResponse(
"Account not found or is not linked to this issuer",
404,
);
}
await db
.delete(OpenIdAccounts)
.where(eq(OpenIdAccounts.id, account.id));
return response(null, 204);
}
}
},
);

View file

@ -0,0 +1,58 @@
import { afterAll, describe, expect, test } from "bun:test";
import { config } from "~packages/config-manager";
import {
deleteOldTestUsers,
getTestUsers,
sendTestRequest,
} from "~tests/utils";
import { meta } from "./index";
await deleteOldTestUsers();
const { deleteUsers, tokens } = await getTestUsers(1);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/sso
describe(meta.route, () => {
test("should return empty list", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), {
method: "GET",
headers: {
Authorization: `Bearer ${tokens[0]?.accessToken}`,
},
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject([]);
});
test("should return an error if provider doesn't exist", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0]?.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
issuer: "unknown",
}),
}),
);
expect(response.status).toBe(404);
expect(await response.json()).toMatchObject({
error: "Issuer unknown not found",
});
});
/*
Unfortunately, we cannot test actual linking, as it requires a valid OpenID provider
setup in config, which we don't have in tests
*/
});

View file

@ -0,0 +1,178 @@
import { randomBytes } from "node:crypto";
import { applyConfig, auth, handleZodError, jsonOrForm } from "@api";
import { oauthRedirectUri } from "@constants";
import { zValidator } from "@hono/zod-validator";
import { errorResponse, jsonResponse } 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", "POST"],
auth: {
required: true,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/api/v1/sso",
});
export const schemas = {
form: z
.object({
issuer: z.string(),
})
.partial(),
};
/**
* SSO Account Linking management endpoint
* A GET request allows the user to list all their linked accounts
* A POST request allows the user to link a new account, and returns a link
*/
export default (app: Hono) =>
app.on(
meta.allowedMethods,
meta.route,
jsonOrForm(),
zValidator("form", schemas.form, handleZodError),
auth(meta.auth),
async (context) => {
const form = context.req.valid("form");
const { user } = context.req.valid("header");
if (!user) {
return errorResponse("Unauthorized", 401);
}
switch (context.req.method) {
case "GET": {
// Get all linked accounts
const accounts = await db.query.OpenIdAccounts.findMany({
where: (user, { eq }) => eq(user.userId, user.id),
});
return jsonResponse(
accounts
.map((account) => {
const issuer = config.oidc.providers.find(
(provider) =>
provider.id === account.issuerId,
);
if (!issuer) {
return null;
}
return {
id: issuer.id,
name: issuer.name,
icon: issuer.icon,
};
})
.filter(Boolean) as {
id: string;
name: string;
icon: string | undefined;
}[],
);
}
case "POST": {
if (!form) {
return errorResponse(
"Missing issuer in form body",
400,
);
}
const { issuer: issuerId } = form;
if (!issuerId) {
return errorResponse(
"Missing issuer in form body",
400,
);
}
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,
)}?${new URLSearchParams({
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()}`,
});
}
}
},
);

View file

@ -64,7 +64,7 @@ describe(meta.route, () => {
},
body: new URLSearchParams({
status: "a".repeat(config.validation.max_note_size + 1),
federate: "false",
local_only: "true",
}),
}),
);
@ -82,7 +82,7 @@ describe(meta.route, () => {
body: new URLSearchParams({
status: "Hello, world!",
visibility: "invalid",
federate: "false",
local_only: "true",
}),
}),
);
@ -100,7 +100,7 @@ describe(meta.route, () => {
body: new URLSearchParams({
status: "Hello, world!",
scheduled_at: "invalid",
federate: "false",
local_only: "true",
}),
}),
);
@ -118,7 +118,7 @@ describe(meta.route, () => {
body: new URLSearchParams({
status: "Hello, world!",
in_reply_to_id: "invalid",
federate: "false",
local_only: "true",
}),
}),
);
@ -136,7 +136,7 @@ describe(meta.route, () => {
body: new URLSearchParams({
status: "Hello, world!",
quote_id: "invalid",
federate: "false",
local_only: "true",
}),
}),
);
@ -154,7 +154,7 @@ describe(meta.route, () => {
body: new URLSearchParams({
status: "Hello, world!",
"media_ids[]": "invalid",
federate: "false",
local_only: "true",
}),
}),
);
@ -171,7 +171,7 @@ describe(meta.route, () => {
},
body: new URLSearchParams({
status: "Hello, world!",
federate: "false",
local_only: "true",
}),
}),
);
@ -196,7 +196,7 @@ describe(meta.route, () => {
body: JSON.stringify({
status: "Hello, world!",
visibility: "unlisted",
federate: "false",
local_only: "true",
}),
}),
);
@ -219,7 +219,7 @@ describe(meta.route, () => {
},
body: new URLSearchParams({
status: "Hello, world!",
federate: "false",
local_only: "true",
}),
}),
);
@ -235,7 +235,7 @@ describe(meta.route, () => {
body: new URLSearchParams({
status: "Hello, world again!",
in_reply_to_id: object.id,
federate: "false",
local_only: "true",
}),
}),
);
@ -258,7 +258,7 @@ describe(meta.route, () => {
},
body: new URLSearchParams({
status: "Hello, world!",
federate: "false",
local_only: "true",
}),
}),
);
@ -274,7 +274,7 @@ describe(meta.route, () => {
body: new URLSearchParams({
status: "Hello, world again!",
quote_id: object.id,
federate: "false",
local_only: "true",
}),
}),
);
@ -300,7 +300,7 @@ describe(meta.route, () => {
},
body: new URLSearchParams({
status: "Hello, :test:!",
federate: "false",
local_only: "true",
}),
}),
);
@ -327,7 +327,7 @@ describe(meta.route, () => {
},
body: new URLSearchParams({
status: `Hello, @${users[1].getUser().username}!`,
federate: "false",
local_only: "true",
}),
}),
);
@ -358,7 +358,7 @@ describe(meta.route, () => {
status: `Hello, @${users[1].getUser().username}@${
new URL(config.http.base_url).host
}!`,
federate: "false",
local_only: "true",
}),
}),
);
@ -389,7 +389,7 @@ describe(meta.route, () => {
},
body: new URLSearchParams({
status: "Hi! <script>alert('Hello, world!');</script>",
federate: "false",
local_only: "true",
}),
}),
);
@ -417,7 +417,7 @@ describe(meta.route, () => {
status: "Hello, world!",
spoiler_text:
"uwu <script>alert('Hello, world!');</script>",
federate: "false",
local_only: "true",
}),
}),
);
@ -443,7 +443,7 @@ describe(meta.route, () => {
},
body: new URLSearchParams({
status: "<img src='https://example.com/image.jpg'> <video src='https://example.com/video.mp4'> Test!",
federate: "false",
local_only: "true",
}),
}),
);

View file

@ -71,16 +71,11 @@ export const schemas = {
.default("public"),
scheduled_at: z.string().optional().nullable(),
local_only: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean())
.optional(),
federate: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean())
.optional()
.default("true"),
.default(false),
}),
};
@ -107,7 +102,7 @@ export default (app: Hono) =>
spoiler_text,
visibility,
content_type,
federate,
local_only,
} = context.req.valid("form");
// Validate status
@ -199,7 +194,7 @@ export default (app: Hono) =>
return errorResponse("Failed to create status", 500);
}
if (federate) {
if (!local_only) {
await federateNote(newNote);
}

View file

@ -27,6 +27,7 @@ describe(meta.route, () => {
"registrations",
"contact",
"rules",
"sso",
]);
});
});

View file

@ -100,5 +100,13 @@ export default (app: Hono) =>
text: rule,
hint: "",
})),
sso: {
forced: false,
providers: config.oidc.providers.map((p) => ({
name: p.name,
icon: p.icon,
id: p.id,
})),
},
});
});

View file

@ -1,111 +0,0 @@
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()}`,
});
},
);

View file

@ -1,27 +1,15 @@
import { randomBytes } from "node:crypto";
import { applyConfig, handleZodError } from "@api";
import { oauthRedirectUri } from "@constants";
import { zValidator } from "@hono/zod-validator";
import { errorResponse, jsonResponse, response } from "@response";
import { errorResponse, response } from "@response";
import type { Hono } from "hono";
import { SignJWT } from "jose";
import {
authorizationCodeGrantRequest,
discoveryRequest,
expectNoState,
getValidatedIdTokenClaims,
isOAuth2Error,
processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse,
processUserInfoResponse,
userInfoRequest,
validateAuthResponse,
} from "oauth4webapi";
import { z } from "zod";
import { TokenType } from "~database/entities/Token";
import { db } from "~drizzle/db";
import { OpenIdAccounts, Tokens } from "~drizzle/schema";
import { Tokens } from "~drizzle/schema";
import { config } from "~packages/config-manager";
import { OAuthManager } from "~packages/database-interface/oauth";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
@ -33,12 +21,12 @@ export const meta = applyConfig({
duration: 60,
max: 20,
},
route: "/oauth/callback/:issuer",
route: "/oauth/sso/:issuer/callback",
});
export const schemas = {
query: z.object({
clientId: z.string().optional(),
client_id: z.string().optional(),
flow: z.string(),
link: z
.string()
@ -68,6 +56,11 @@ const returnError = (query: object, error: string, description: string) => {
});
};
/**
* OAuth Callback endpoint
* After the user has authenticated to an external OpenID provider,
* they are redirected here to complete the OAuth flow and get a code
*/
export default (app: Hono) =>
app.on(
meta.allowedMethods,
@ -82,151 +75,30 @@ export default (app: Hono) =>
const { issuer: issuerParam } = context.req.valid("param");
const { flow: flowId, user_id, link } = context.req.valid("query");
const flow = await db.query.OpenIdLoginFlows.findFirst({
where: (flow, { eq }) => eq(flow.id, flowId),
with: {
application: true,
},
});
const manager = new OAuthManager(issuerParam);
if (!flow) {
return returnError(
context.req.query(),
"invalid_request",
"Invalid flow",
);
}
const issuer = config.oidc.providers.find(
(provider) => provider.id === issuerParam,
);
if (!issuer) {
return returnError(
context.req.query(),
"invalid_request",
"Invalid issuer",
);
}
const issuerUrl = new URL(issuer.url);
const authServer = await discoveryRequest(issuerUrl, {
algorithm: "oidc",
}).then((res) => processDiscoveryResponse(issuerUrl, res));
const parameters = validateAuthResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
const userInfo = await manager.automaticOidcFlow(
flowId,
currentUrl,
// Whether to expect state or not
expectNoState,
(error, message, app) =>
returnError(
{
...manager.processOAuth2Error(app),
link: link ? "true" : undefined,
},
error,
message,
),
);
if (isOAuth2Error(parameters)) {
return returnError(
{
redirect_uri: flow.application?.redirectUri,
client_id: flow.application?.clientId,
response_type: "code",
scope: flow.application?.scopes,
},
parameters.error,
parameters.error_description || "",
);
}
if (userInfo instanceof Response) return userInfo;
const oidcResponse = await authorizationCodeGrantRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
parameters,
`${oauthRedirectUri(issuerParam)}?flow=${flow.id}`,
flow.codeVerifier,
);
const result = await processAuthorizationCodeOpenIDResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
oidcResponse,
);
if (isOAuth2Error(result)) {
return returnError(
{
redirect_uri: flow.application?.redirectUri,
client_id: flow.application?.clientId,
response_type: "code",
scope: flow.application?.scopes,
},
result.error,
result.error_description || "",
);
}
const { access_token } = result;
const claims = getValidatedIdTokenClaims(result);
const { sub } = claims;
// Validate `sub`
// Later, we'll use this to automatically set the user's data
await userInfoRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
access_token,
).then((res) =>
processUserInfoResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
sub,
res,
),
);
const { sub } = userInfo.userInfo;
const flow = userInfo.flow;
// If linking account
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,
});
return await manager.linkUser(user_id, userInfo);
}
const userId = (
@ -234,7 +106,7 @@ export default (app: Hono) =>
where: (account, { eq, and }) =>
and(
eq(account.serverId, sub),
eq(account.issuerId, issuer.id),
eq(account.issuerId, manager.issuer.id),
),
})
)?.userId;

View file

@ -23,13 +23,13 @@ export const meta = applyConfig({
duration: 60,
max: 20,
},
route: "/oauth/authorize-external",
route: "/oauth/sso",
});
export const schemas = {
query: z.object({
issuer: z.string(),
clientId: z.string().optional(),
client_id: z.string().optional(),
}),
};
@ -57,10 +57,10 @@ export default (app: Hono) =>
zValidator("query", schemas.query, handleZodError),
async (context) => {
// This is the Lysand client's client_id, not the external OAuth provider's client_id
const { issuer: issuerId, clientId } = context.req.valid("query");
const { issuer: issuerId, client_id } = context.req.valid("query");
const body = await context.req.query();
if (!clientId || clientId === "undefined") {
if (!client_id || client_id === "undefined") {
return returnError(
body,
"invalid_request",
@ -90,7 +90,7 @@ export default (app: Hono) =>
const application = await db.query.Applications.findFirst({
where: (application, { eq }) =>
eq(application.clientId, clientId),
eq(application.clientId, client_id),
});
if (!application) {

View file

@ -67,7 +67,7 @@ describe("API Tests", () => {
status: "Hello, world!",
visibility: "public",
"media_ids[]": media1?.id ?? "",
federate: "false",
local_only: "true",
}),
},
),
@ -114,7 +114,7 @@ describe("API Tests", () => {
status: "This is a reply!",
visibility: "public",
in_reply_to_id: status?.id ?? "",
federate: "false",
local_only: "true",
}),
},
),

View file

@ -1,4 +1,4 @@
import { config } from "config-manager";
export const oauthRedirectUri = (issuer: string) =>
new URL(`/oauth/callback/${issuer}`, config.http.base_url).toString();
new URL(`/oauth/sso/${issuer}/callback`, config.http.base_url).toString();