mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +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 { OAuthManager } from "./oauth";
|
||||
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({
|
||||
status: "Reply",
|
||||
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,
|
||||
},
|
||||
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;
|
||||
}[];
|
||||
};
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ beforeAll(async () => {
|
|||
body: new URLSearchParams({
|
||||
status: `@${users[0].getUser().username} test mention`,
|
||||
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({
|
||||
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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ describe(meta.route, () => {
|
|||
"registrations",
|
||||
"contact",
|
||||
"rules",
|
||||
"sso",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { 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;
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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",
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue