mirror of
https://github.com/versia-pub/server.git
synced 2025-12-07 00:48:18 +01:00
refactor(api): ♻️ Change route names, improve API endpoints to be more consistent with Mastodon API
This commit is contained in:
parent
a6eb826b04
commit
b1216a43f2
|
|
@ -1,4 +1,5 @@
|
||||||
import { Note } from "./note";
|
import { Note } from "./note";
|
||||||
|
import { OAuthManager } from "./oauth";
|
||||||
import { Timeline } from "./timeline";
|
import { Timeline } from "./timeline";
|
||||||
|
|
||||||
export { Note, Timeline };
|
export { Note, Timeline, OAuthManager };
|
||||||
|
|
|
||||||
270
packages/database-interface/oauth.ts
Normal file
270
packages/database-interface/oauth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -102,7 +102,7 @@ describe(meta.route, () => {
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Reply",
|
status: "Reply",
|
||||||
in_reply_to_id: timeline[0].id,
|
in_reply_to_id: timeline[0].id,
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
21
server/api/api/v1/frontend/config/index.ts
Normal file
21
server/api/api/v1/frontend/config/index.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -80,7 +80,7 @@ export default (app: Hono) =>
|
||||||
user_count: userCount,
|
user_count: userCount,
|
||||||
},
|
},
|
||||||
thumbnail: proxyUrl(config.instance.logo),
|
thumbnail: proxyUrl(config.instance.logo),
|
||||||
banner: proxyUrl(config.instance.banner) ?? "",
|
banner: proxyUrl(config.instance.banner),
|
||||||
title: config.instance.name,
|
title: config.instance.name,
|
||||||
uri: config.http.base_url,
|
uri: config.http.base_url,
|
||||||
urls: {
|
urls: {
|
||||||
|
|
@ -88,79 +88,25 @@ export default (app: Hono) =>
|
||||||
},
|
},
|
||||||
version: "4.3.0-alpha.3+glitch",
|
version: "4.3.0-alpha.3+glitch",
|
||||||
lysand_version: version,
|
lysand_version: version,
|
||||||
pleroma: {
|
sso: {
|
||||||
metadata: {
|
forced: false,
|
||||||
account_activation_required: false,
|
providers: config.oidc.providers.map((p) => ({
|
||||||
features: [
|
name: p.name,
|
||||||
"pleroma_api",
|
icon: p.icon,
|
||||||
"akkoma_api",
|
id: p.id,
|
||||||
"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: "",
|
|
||||||
},
|
},
|
||||||
contact_account: contactAccount?.toAPI() || undefined,
|
contact_account: contactAccount?.toAPI() || undefined,
|
||||||
} satisfies APIInstance & {
|
} satisfies APIInstance & {
|
||||||
banner: string;
|
banner: string | null;
|
||||||
lysand_version: string;
|
lysand_version: string;
|
||||||
pleroma: object;
|
sso: {
|
||||||
|
forced: boolean;
|
||||||
|
providers: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ beforeAll(async () => {
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: `@${users[0].getUser().username} test mention`,
|
status: `@${users[0].getUser().username} test mention`,
|
||||||
visibility: "direct",
|
visibility: "direct",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
67
server/api/api/v1/sso/:id/index.test.ts
Normal file
67
server/api/api/v1/sso/:id/index.test.ts
Normal 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
|
||||||
|
*/
|
||||||
|
});
|
||||||
104
server/api/api/v1/sso/:id/index.ts
Normal file
104
server/api/api/v1/sso/:id/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
58
server/api/api/v1/sso/index.test.ts
Normal file
58
server/api/api/v1/sso/index.test.ts
Normal 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
|
||||||
|
*/
|
||||||
|
});
|
||||||
178
server/api/api/v1/sso/index.ts
Normal file
178
server/api/api/v1/sso/index.ts
Normal 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()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -64,7 +64,7 @@ describe(meta.route, () => {
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "a".repeat(config.validation.max_note_size + 1),
|
status: "a".repeat(config.validation.max_note_size + 1),
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -82,7 +82,7 @@ describe(meta.route, () => {
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hello, world!",
|
status: "Hello, world!",
|
||||||
visibility: "invalid",
|
visibility: "invalid",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -100,7 +100,7 @@ describe(meta.route, () => {
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hello, world!",
|
status: "Hello, world!",
|
||||||
scheduled_at: "invalid",
|
scheduled_at: "invalid",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -118,7 +118,7 @@ describe(meta.route, () => {
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hello, world!",
|
status: "Hello, world!",
|
||||||
in_reply_to_id: "invalid",
|
in_reply_to_id: "invalid",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -136,7 +136,7 @@ describe(meta.route, () => {
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hello, world!",
|
status: "Hello, world!",
|
||||||
quote_id: "invalid",
|
quote_id: "invalid",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -154,7 +154,7 @@ describe(meta.route, () => {
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hello, world!",
|
status: "Hello, world!",
|
||||||
"media_ids[]": "invalid",
|
"media_ids[]": "invalid",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -171,7 +171,7 @@ describe(meta.route, () => {
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hello, world!",
|
status: "Hello, world!",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -196,7 +196,7 @@ describe(meta.route, () => {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
status: "Hello, world!",
|
status: "Hello, world!",
|
||||||
visibility: "unlisted",
|
visibility: "unlisted",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -219,7 +219,7 @@ describe(meta.route, () => {
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hello, world!",
|
status: "Hello, world!",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -235,7 +235,7 @@ describe(meta.route, () => {
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hello, world again!",
|
status: "Hello, world again!",
|
||||||
in_reply_to_id: object.id,
|
in_reply_to_id: object.id,
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -258,7 +258,7 @@ describe(meta.route, () => {
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hello, world!",
|
status: "Hello, world!",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -274,7 +274,7 @@ describe(meta.route, () => {
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hello, world again!",
|
status: "Hello, world again!",
|
||||||
quote_id: object.id,
|
quote_id: object.id,
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -300,7 +300,7 @@ describe(meta.route, () => {
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hello, :test:!",
|
status: "Hello, :test:!",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -327,7 +327,7 @@ describe(meta.route, () => {
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: `Hello, @${users[1].getUser().username}!`,
|
status: `Hello, @${users[1].getUser().username}!`,
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -358,7 +358,7 @@ describe(meta.route, () => {
|
||||||
status: `Hello, @${users[1].getUser().username}@${
|
status: `Hello, @${users[1].getUser().username}@${
|
||||||
new URL(config.http.base_url).host
|
new URL(config.http.base_url).host
|
||||||
}!`,
|
}!`,
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -389,7 +389,7 @@ describe(meta.route, () => {
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "Hi! <script>alert('Hello, world!');</script>",
|
status: "Hi! <script>alert('Hello, world!');</script>",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -417,7 +417,7 @@ describe(meta.route, () => {
|
||||||
status: "Hello, world!",
|
status: "Hello, world!",
|
||||||
spoiler_text:
|
spoiler_text:
|
||||||
"uwu <script>alert('Hello, world!');</script>",
|
"uwu <script>alert('Hello, world!');</script>",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -443,7 +443,7 @@ describe(meta.route, () => {
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
status: "<img src='https://example.com/image.jpg'> <video src='https://example.com/video.mp4'> Test!",
|
status: "<img src='https://example.com/image.jpg'> <video src='https://example.com/video.mp4'> Test!",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -71,16 +71,11 @@ export const schemas = {
|
||||||
.default("public"),
|
.default("public"),
|
||||||
scheduled_at: z.string().optional().nullable(),
|
scheduled_at: z.string().optional().nullable(),
|
||||||
local_only: z
|
local_only: z
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.or(z.boolean())
|
|
||||||
.optional(),
|
|
||||||
federate: z
|
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||||
.or(z.boolean())
|
.or(z.boolean())
|
||||||
.optional()
|
.optional()
|
||||||
.default("true"),
|
.default(false),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -107,7 +102,7 @@ export default (app: Hono) =>
|
||||||
spoiler_text,
|
spoiler_text,
|
||||||
visibility,
|
visibility,
|
||||||
content_type,
|
content_type,
|
||||||
federate,
|
local_only,
|
||||||
} = context.req.valid("form");
|
} = context.req.valid("form");
|
||||||
|
|
||||||
// Validate status
|
// Validate status
|
||||||
|
|
@ -199,7 +194,7 @@ export default (app: Hono) =>
|
||||||
return errorResponse("Failed to create status", 500);
|
return errorResponse("Failed to create status", 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (federate) {
|
if (!local_only) {
|
||||||
await federateNote(newNote);
|
await federateNote(newNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ describe(meta.route, () => {
|
||||||
"registrations",
|
"registrations",
|
||||||
"contact",
|
"contact",
|
||||||
"rules",
|
"rules",
|
||||||
|
"sso",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -100,5 +100,13 @@ export default (app: Hono) =>
|
||||||
text: rule,
|
text: rule,
|
||||||
hint: "",
|
hint: "",
|
||||||
})),
|
})),
|
||||||
|
sso: {
|
||||||
|
forced: false,
|
||||||
|
providers: config.oidc.providers.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
icon: p.icon,
|
||||||
|
id: p.id,
|
||||||
|
})),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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()}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,27 +1,15 @@
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { applyConfig, handleZodError } from "@api";
|
import { applyConfig, handleZodError } from "@api";
|
||||||
import { oauthRedirectUri } from "@constants";
|
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, jsonResponse, response } from "@response";
|
import { errorResponse, response } from "@response";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { SignJWT } from "jose";
|
import { SignJWT } from "jose";
|
||||||
import {
|
|
||||||
authorizationCodeGrantRequest,
|
|
||||||
discoveryRequest,
|
|
||||||
expectNoState,
|
|
||||||
getValidatedIdTokenClaims,
|
|
||||||
isOAuth2Error,
|
|
||||||
processAuthorizationCodeOpenIDResponse,
|
|
||||||
processDiscoveryResponse,
|
|
||||||
processUserInfoResponse,
|
|
||||||
userInfoRequest,
|
|
||||||
validateAuthResponse,
|
|
||||||
} from "oauth4webapi";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TokenType } from "~database/entities/Token";
|
import { TokenType } from "~database/entities/Token";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { OpenIdAccounts, Tokens } from "~drizzle/schema";
|
import { Tokens } from "~drizzle/schema";
|
||||||
import { config } from "~packages/config-manager";
|
import { config } from "~packages/config-manager";
|
||||||
|
import { OAuthManager } from "~packages/database-interface/oauth";
|
||||||
import { User } from "~packages/database-interface/user";
|
import { User } from "~packages/database-interface/user";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -33,12 +21,12 @@ export const meta = applyConfig({
|
||||||
duration: 60,
|
duration: 60,
|
||||||
max: 20,
|
max: 20,
|
||||||
},
|
},
|
||||||
route: "/oauth/callback/:issuer",
|
route: "/oauth/sso/:issuer/callback",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemas = {
|
export const schemas = {
|
||||||
query: z.object({
|
query: z.object({
|
||||||
clientId: z.string().optional(),
|
client_id: z.string().optional(),
|
||||||
flow: z.string(),
|
flow: z.string(),
|
||||||
link: z
|
link: z
|
||||||
.string()
|
.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) =>
|
export default (app: Hono) =>
|
||||||
app.on(
|
app.on(
|
||||||
meta.allowedMethods,
|
meta.allowedMethods,
|
||||||
|
|
@ -82,151 +75,30 @@ export default (app: Hono) =>
|
||||||
const { issuer: issuerParam } = context.req.valid("param");
|
const { issuer: issuerParam } = context.req.valid("param");
|
||||||
const { flow: flowId, user_id, link } = context.req.valid("query");
|
const { flow: flowId, user_id, link } = context.req.valid("query");
|
||||||
|
|
||||||
const flow = await db.query.OpenIdLoginFlows.findFirst({
|
const manager = new OAuthManager(issuerParam);
|
||||||
where: (flow, { eq }) => eq(flow.id, flowId),
|
|
||||||
with: {
|
|
||||||
application: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!flow) {
|
const userInfo = await manager.automaticOidcFlow(
|
||||||
return returnError(
|
flowId,
|
||||||
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,
|
|
||||||
},
|
|
||||||
currentUrl,
|
currentUrl,
|
||||||
// Whether to expect state or not
|
(error, message, app) =>
|
||||||
expectNoState,
|
returnError(
|
||||||
);
|
|
||||||
|
|
||||||
if (isOAuth2Error(parameters)) {
|
|
||||||
return returnError(
|
|
||||||
{
|
{
|
||||||
redirect_uri: flow.application?.redirectUri,
|
...manager.processOAuth2Error(app),
|
||||||
client_id: flow.application?.clientId,
|
link: link ? "true" : undefined,
|
||||||
response_type: "code",
|
|
||||||
scope: flow.application?.scopes,
|
|
||||||
},
|
},
|
||||||
parameters.error,
|
error,
|
||||||
parameters.error_description || "",
|
message,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (userInfo instanceof Response) return userInfo;
|
||||||
|
|
||||||
|
const { sub } = userInfo.userInfo;
|
||||||
|
const flow = userInfo.flow;
|
||||||
|
|
||||||
|
// If linking account
|
||||||
if (link && user_id) {
|
if (link && user_id) {
|
||||||
// Check if userId is equal to application.clientId
|
return await manager.linkUser(user_id, userInfo);
|
||||||
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 = (
|
const userId = (
|
||||||
|
|
@ -234,7 +106,7 @@ export default (app: Hono) =>
|
||||||
where: (account, { eq, and }) =>
|
where: (account, { eq, and }) =>
|
||||||
and(
|
and(
|
||||||
eq(account.serverId, sub),
|
eq(account.serverId, sub),
|
||||||
eq(account.issuerId, issuer.id),
|
eq(account.issuerId, manager.issuer.id),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
)?.userId;
|
)?.userId;
|
||||||
|
|
@ -23,13 +23,13 @@ export const meta = applyConfig({
|
||||||
duration: 60,
|
duration: 60,
|
||||||
max: 20,
|
max: 20,
|
||||||
},
|
},
|
||||||
route: "/oauth/authorize-external",
|
route: "/oauth/sso",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemas = {
|
export const schemas = {
|
||||||
query: z.object({
|
query: z.object({
|
||||||
issuer: z.string(),
|
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),
|
zValidator("query", schemas.query, handleZodError),
|
||||||
async (context) => {
|
async (context) => {
|
||||||
// This is the Lysand client's client_id, not the external OAuth provider's client_id
|
// 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();
|
const body = await context.req.query();
|
||||||
|
|
||||||
if (!clientId || clientId === "undefined") {
|
if (!client_id || client_id === "undefined") {
|
||||||
return returnError(
|
return returnError(
|
||||||
body,
|
body,
|
||||||
"invalid_request",
|
"invalid_request",
|
||||||
|
|
@ -90,7 +90,7 @@ export default (app: Hono) =>
|
||||||
|
|
||||||
const application = await db.query.Applications.findFirst({
|
const application = await db.query.Applications.findFirst({
|
||||||
where: (application, { eq }) =>
|
where: (application, { eq }) =>
|
||||||
eq(application.clientId, clientId),
|
eq(application.clientId, client_id),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!application) {
|
if (!application) {
|
||||||
|
|
@ -67,7 +67,7 @@ describe("API Tests", () => {
|
||||||
status: "Hello, world!",
|
status: "Hello, world!",
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
"media_ids[]": media1?.id ?? "",
|
"media_ids[]": media1?.id ?? "",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -114,7 +114,7 @@ describe("API Tests", () => {
|
||||||
status: "This is a reply!",
|
status: "This is a reply!",
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
in_reply_to_id: status?.id ?? "",
|
in_reply_to_id: status?.id ?? "",
|
||||||
federate: "false",
|
local_only: "true",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
|
|
||||||
export const oauthRedirectUri = (issuer: string) =>
|
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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue