mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(config): ✨ Allow frontend route customization and forcing OIDC
This commit is contained in:
parent
b34166de93
commit
2db4f25ba6
20
README.md
20
README.md
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- [x] Federation (partial)
|
- [x] Fully compliant Lysand 3.0 federation (partial)
|
||||||
- [x] Hyper fast (thousands of HTTP requests per second)
|
- [x] Hyper fast (thousands of HTTP requests per second)
|
||||||
- [x] S3 or local media storage
|
- [x] S3 or local media storage
|
||||||
- [x] Deduplication of uploaded files
|
- [x] Deduplication of uploaded files
|
||||||
|
|
@ -32,11 +32,21 @@
|
||||||
- [x] Configurable defaults
|
- [x] Configurable defaults
|
||||||
- [x] Full regex-based filters for posts, users and media
|
- [x] Full regex-based filters for posts, users and media
|
||||||
- [x] Custom emoji support
|
- [x] Custom emoji support
|
||||||
|
- [x] Users can upload their own emojis for themselves
|
||||||
- [x] Automatic image conversion to WebP or other formats
|
- [x] Automatic image conversion to WebP or other formats
|
||||||
- [x] Scripting-compatible CLI with JSON and CSV outputs
|
- [x] Scripting-compatible CLI with JSON and CSV outputs
|
||||||
- [x] Markdown support just about everywhere: posts, profiles, profile fields, etc. Code blocks, tables, and more are supported.
|
- [x] Markdown support just about everywhere: posts, profiles, profile fields, etc. Code blocks, tables, and more are supported.
|
||||||
- [ ] Moderation tools
|
- [ ] Moderation tools
|
||||||
- [x] Mastodon API support (partial)
|
- [x] Fully compliant Mastodon API support (partial)
|
||||||
|
- [x] Glitch-SOC extensions
|
||||||
|
- [x] Full compatibility with many clients such as Megalodon
|
||||||
|
- [x] Ability to use your own frontends
|
||||||
|
- [x] Non-monolithic architecture, microservices can be hosted in infinite amounts on infinite servers
|
||||||
|
- [x] Ability to use all your threads
|
||||||
|
- [x] Support for SSO providers (and disabling password logins)
|
||||||
|
- [x] Fully written in TypeScript and thoroughly unit tested
|
||||||
|
- [x] Automatic signed container builds for easy deployment
|
||||||
|
- [x] Docker and Podman supported
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
|
@ -174,7 +184,7 @@ Working endpoints are:
|
||||||
- [ ] `/oauth/revoke`
|
- [ ] `/oauth/revoke`
|
||||||
- Admin API
|
- Admin API
|
||||||
|
|
||||||
### Main work to do
|
### Main work to do for API
|
||||||
|
|
||||||
- [ ] Announcements
|
- [ ] Announcements
|
||||||
- [ ] Polls
|
- [ ] Polls
|
||||||
|
|
@ -190,6 +200,10 @@ Working endpoints are:
|
||||||
- [ ] Reports
|
- [ ] Reports
|
||||||
- [ ] Admin API
|
- [ ] Admin API
|
||||||
|
|
||||||
|
## Lysand API
|
||||||
|
|
||||||
|
For Lysand's own custom API, please see the [API documentation](docs/api/index.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the [AGPL-3.0-or-later](LICENSE).
|
This project is licensed under the [AGPL-3.0-or-later](LICENSE).
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,12 @@ rules = [
|
||||||
# Run Lysand with this value missing to generate a new key
|
# Run Lysand with this value missing to generate a new key
|
||||||
jwt_key = ""
|
jwt_key = ""
|
||||||
|
|
||||||
|
# If enabled, Lysand will require users to log in with an OAuth provider
|
||||||
|
# Note that registering with an OAuth provider is not supported yet, so
|
||||||
|
# this will lock out users who are not already registered or who do not have
|
||||||
|
# an OAuth account linked
|
||||||
|
forced = false
|
||||||
|
|
||||||
# Delete this section if you don't want to use custom OAuth providers
|
# Delete this section if you don't want to use custom OAuth providers
|
||||||
# This is an example configuration
|
# This is an example configuration
|
||||||
# The provider MUST support OpenID Connect with .well-known discovery
|
# The provider MUST support OpenID Connect with .well-known discovery
|
||||||
|
|
@ -99,6 +105,15 @@ enabled = true
|
||||||
# The URL to reach the frontend at (should be on a local network)
|
# The URL to reach the frontend at (should be on a local network)
|
||||||
url = "http://localhost:3000"
|
url = "http://localhost:3000"
|
||||||
|
|
||||||
|
[frontend.routes]
|
||||||
|
# Special routes for your frontend, below are the defaults for Lysand-FE
|
||||||
|
# Can be set to a route already used by Lysand, as long as it is on a different HTTP method
|
||||||
|
# e.g. /oauth/authorize is a POST-only route, so you can serve a GET route at /oauth/authorize
|
||||||
|
# home = "/"
|
||||||
|
# login = "/oauth/authorize"
|
||||||
|
# consent = "/oauth/consent"
|
||||||
|
# register = "/register"
|
||||||
|
|
||||||
[frontend.settings]
|
[frontend.settings]
|
||||||
# Arbitrary key/value pairs to be passed to the frontend
|
# Arbitrary key/value pairs to be passed to the frontend
|
||||||
# This can be used to set up custom themes, etc on supported frontends.
|
# This can be used to set up custom themes, etc on supported frontends.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ The frontend API contains endpoints that are useful for frontend developers. The
|
||||||
|
|
||||||
## Routes that the Frontend must implement
|
## Routes that the Frontend must implement
|
||||||
|
|
||||||
- `GET /oauth/authorize` (NOT `POST`): Identifier/password login form, submits to [`POST /api/auth/login`](#sign-in) or OpenID Connect flow.
|
These routes can be set to a different URL in the Lysand configuration, at `frontend.routes`. The frontend must implement these routes for the instance to function correctly.
|
||||||
|
|
||||||
|
- `GET /oauth/authorize`: (NOT `POST`): Identifier/password login form, submits to [`POST /api/auth/login`](#sign-in) or OpenID Connect flow.
|
||||||
- `GET /oauth/consent`: Consent form, submits to [`POST /api/auth/redirect`](#consent)
|
- `GET /oauth/consent`: Consent form, submits to [`POST /api/auth/redirect`](#consent)
|
||||||
|
|
||||||
## Get Frontend Configuration
|
## Get Frontend Configuration
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,20 @@ export enum MediaBackendType {
|
||||||
S3 = "s3",
|
S3 = "s3",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const zUrlPath = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
// Remove trailing slashes, but keep the root slash
|
||||||
|
.transform((arg) => (arg === "/" ? arg : arg.replace(/\/$/, "")));
|
||||||
|
|
||||||
|
const zUrl = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.refine((arg) => URL.canParse(arg), "Invalid url")
|
||||||
|
.transform((arg) => arg.replace(/\/$/, ""));
|
||||||
|
|
||||||
export const configValidator = z.object({
|
export const configValidator = z.object({
|
||||||
database: z.object({
|
database: z.object({
|
||||||
host: z.string().min(1).default("localhost"),
|
host: z.string().min(1).default("localhost"),
|
||||||
|
|
@ -78,6 +92,7 @@ export const configValidator = z.object({
|
||||||
rules: z.array(z.string()).default([]),
|
rules: z.array(z.string()).default([]),
|
||||||
}),
|
}),
|
||||||
oidc: z.object({
|
oidc: z.object({
|
||||||
|
forced: z.boolean().default(false),
|
||||||
providers: z
|
providers: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|
@ -136,18 +151,31 @@ export const configValidator = z.object({
|
||||||
frontend: z
|
frontend: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
url: z.string().min(1).url().default("http://localhost:3000"),
|
url: zUrl.default("http://localhost:3000"),
|
||||||
glitch: z
|
glitch: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
assets: z.string().min(1).default("glitch"),
|
assets: z.string().min(1).default("glitch"),
|
||||||
server: z.array(z.string().url().min(1)).default([]),
|
server: z.array(zUrl).default([]),
|
||||||
})
|
})
|
||||||
.default({
|
.default({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
assets: "glitch",
|
assets: "glitch",
|
||||||
server: [],
|
server: [],
|
||||||
}),
|
}),
|
||||||
|
routes: z
|
||||||
|
.object({
|
||||||
|
home: zUrlPath.default("/"),
|
||||||
|
login: zUrlPath.default("/oauth/authorize"),
|
||||||
|
consent: zUrlPath.default("/oauth/consent"),
|
||||||
|
register: zUrlPath.default("/register"),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
|
home: "/",
|
||||||
|
login: "/oauth/authorize",
|
||||||
|
consent: "/oauth/consent",
|
||||||
|
register: "/register",
|
||||||
|
}),
|
||||||
settings: z.record(z.string(), z.any()).default({}),
|
settings: z.record(z.string(), z.any()).default({}),
|
||||||
})
|
})
|
||||||
.default({
|
.default({
|
||||||
|
|
@ -215,7 +243,7 @@ export const configValidator = z.object({
|
||||||
secret_access_key: z.string(),
|
secret_access_key: z.string(),
|
||||||
region: z.string().optional(),
|
region: z.string().optional(),
|
||||||
bucket_name: z.string().default("lysand"),
|
bucket_name: z.string().default("lysand"),
|
||||||
public_url: z.string().url(),
|
public_url: zUrl,
|
||||||
})
|
})
|
||||||
.default({
|
.default({
|
||||||
endpoint: "",
|
endpoint: "",
|
||||||
|
|
@ -374,8 +402,8 @@ export const configValidator = z.object({
|
||||||
.object({
|
.object({
|
||||||
visibility: z.string().default("public"),
|
visibility: z.string().default("public"),
|
||||||
language: z.string().default("en"),
|
language: z.string().default("en"),
|
||||||
avatar: z.string().url().optional(),
|
avatar: zUrl.optional(),
|
||||||
header: z.string().url().optional(),
|
header: zUrl.optional(),
|
||||||
placeholder_style: z.string().default("thumbs"),
|
placeholder_style: z.string().default("thumbs"),
|
||||||
})
|
})
|
||||||
.default({
|
.default({
|
||||||
|
|
@ -387,18 +415,18 @@ export const configValidator = z.object({
|
||||||
}),
|
}),
|
||||||
federation: z
|
federation: z
|
||||||
.object({
|
.object({
|
||||||
blocked: z.array(z.string().url()).default([]),
|
blocked: z.array(zUrl).default([]),
|
||||||
followers_only: z.array(z.string().url()).default([]),
|
followers_only: z.array(zUrl).default([]),
|
||||||
discard: z.object({
|
discard: z.object({
|
||||||
reports: z.array(z.string().url()).default([]),
|
reports: z.array(zUrl).default([]),
|
||||||
deletes: z.array(z.string().url()).default([]),
|
deletes: z.array(zUrl).default([]),
|
||||||
updates: z.array(z.string().url()).default([]),
|
updates: z.array(zUrl).default([]),
|
||||||
media: z.array(z.string().url()).default([]),
|
media: z.array(zUrl).default([]),
|
||||||
follows: z.array(z.string().url()).default([]),
|
follows: z.array(zUrl).default([]),
|
||||||
likes: z.array(z.string().url()).default([]),
|
likes: z.array(zUrl).default([]),
|
||||||
reactions: z.array(z.string().url()).default([]),
|
reactions: z.array(zUrl).default([]),
|
||||||
banners: z.array(z.string().url()).default([]),
|
banners: z.array(zUrl).default([]),
|
||||||
avatars: z.array(z.string().url()).default([]),
|
avatars: z.array(zUrl).default([]),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.default({
|
.default({
|
||||||
|
|
@ -421,8 +449,8 @@ export const configValidator = z.object({
|
||||||
name: z.string().min(1).default("Lysand"),
|
name: z.string().min(1).default("Lysand"),
|
||||||
description: z.string().min(1).default("A Lysand instance"),
|
description: z.string().min(1).default("A Lysand instance"),
|
||||||
extended_description_path: z.string().optional(),
|
extended_description_path: z.string().optional(),
|
||||||
logo: z.string().url().optional(),
|
logo: zUrl.optional(),
|
||||||
banner: z.string().url().optional(),
|
banner: zUrl.optional(),
|
||||||
})
|
})
|
||||||
.default({
|
.default({
|
||||||
name: "Lysand",
|
name: "Lysand",
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,9 @@ export class OAuthManager {
|
||||||
// Check if userId is equal to application.clientId
|
// Check if userId is equal to application.clientId
|
||||||
if ((flow.application?.clientId ?? "") !== userId) {
|
if ((flow.application?.clientId ?? "") !== userId) {
|
||||||
return response(null, 302, {
|
return response(null, 302, {
|
||||||
Location: `${config.http.base_url}?${new URLSearchParams({
|
Location: `${config.http.base_url}${
|
||||||
|
config.frontend.routes.home
|
||||||
|
}?${new URLSearchParams({
|
||||||
oidc_account_linking_error: "Account linking error",
|
oidc_account_linking_error: "Account linking error",
|
||||||
oidc_account_linking_error_message: `User ID does not match application client ID (${userId} != ${flow.application?.clientId})`,
|
oidc_account_linking_error_message: `User ID does not match application client ID (${userId} != ${flow.application?.clientId})`,
|
||||||
})}`,
|
})}`,
|
||||||
|
|
@ -170,7 +172,9 @@ export class OAuthManager {
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
return response(null, 302, {
|
return response(null, 302, {
|
||||||
Location: `${config.http.base_url}?${new URLSearchParams({
|
Location: `${config.http.base_url}${
|
||||||
|
config.frontend.routes.home
|
||||||
|
}?${new URLSearchParams({
|
||||||
oidc_account_linking_error: "Account already linked",
|
oidc_account_linking_error: "Account already linked",
|
||||||
oidc_account_linking_error_message:
|
oidc_account_linking_error_message:
|
||||||
"This account has already been linked to this OpenID Connect provider.",
|
"This account has already been linked to this OpenID Connect provider.",
|
||||||
|
|
@ -186,7 +190,9 @@ export class OAuthManager {
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(null, 302, {
|
return response(null, 302, {
|
||||||
Location: `${config.http.base_url}?${new URLSearchParams({
|
Location: `${config.http.base_url}${
|
||||||
|
config.frontend.routes.home
|
||||||
|
}?${new URLSearchParams({
|
||||||
oidc_account_linked: "true",
|
oidc_account_linked: "true",
|
||||||
})}`,
|
})}`,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ const returnError = (query: object, error: string, description: string) => {
|
||||||
|
|
||||||
return response(null, 302, {
|
return response(null, 302, {
|
||||||
Location: new URL(
|
Location: new URL(
|
||||||
`/oauth/authorize?${searchParams.toString()}`,
|
`${config.frontend.routes.login}?${searchParams.toString()}`,
|
||||||
config.http.base_url,
|
config.http.base_url,
|
||||||
).toString(),
|
).toString(),
|
||||||
});
|
});
|
||||||
|
|
@ -87,6 +87,14 @@ export default (app: Hono) =>
|
||||||
zValidator("form", schemas.form, handleZodError),
|
zValidator("form", schemas.form, handleZodError),
|
||||||
zValidator("query", schemas.query, handleZodError),
|
zValidator("query", schemas.query, handleZodError),
|
||||||
async (context) => {
|
async (context) => {
|
||||||
|
if (config.oidc.forced) {
|
||||||
|
return returnError(
|
||||||
|
context.req.query(),
|
||||||
|
"invalid_request",
|
||||||
|
"Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { identifier, password } = context.req.valid("form");
|
const { identifier, password } = context.req.valid("form");
|
||||||
const { client_id } = context.req.valid("query");
|
const { client_id } = context.req.valid("query");
|
||||||
|
|
||||||
|
|
@ -160,7 +168,9 @@ export default (app: Hono) =>
|
||||||
// Redirect to OAuth authorize with JWT
|
// Redirect to OAuth authorize with JWT
|
||||||
return response(null, 302, {
|
return response(null, 302, {
|
||||||
Location: new URL(
|
Location: new URL(
|
||||||
`/oauth/consent?${searchParams.toString()}`,
|
`${
|
||||||
|
config.frontend.routes.consent
|
||||||
|
}?${searchParams.toString()}`,
|
||||||
config.http.base_url,
|
config.http.base_url,
|
||||||
).toString(),
|
).toString(),
|
||||||
// Set cookie with JWT
|
// Set cookie with JWT
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Applications, Tokens } from "~drizzle/schema";
|
import { Applications, Tokens } from "~drizzle/schema";
|
||||||
|
import { config } from "~packages/config-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -40,7 +41,7 @@ export default (app: Hono) =>
|
||||||
|
|
||||||
const redirectToLogin = (error: string) =>
|
const redirectToLogin = (error: string) =>
|
||||||
Response.redirect(
|
Response.redirect(
|
||||||
`/oauth/authorize?${new URLSearchParams({
|
`${config.frontend.routes.login}?${new URLSearchParams({
|
||||||
...context.req.query,
|
...context.req.query,
|
||||||
error: encodeURIComponent(error),
|
error: encodeURIComponent(error),
|
||||||
}).toString()}`,
|
}).toString()}`,
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ const returnError = (query: object, error: string, description: string) => {
|
||||||
searchParams.append("error_description", description);
|
searchParams.append("error_description", description);
|
||||||
|
|
||||||
return response(null, 302, {
|
return response(null, 302, {
|
||||||
Location: `/oauth/authorize?${searchParams.toString()}`,
|
Location: `${config.frontend.routes.login}?${searchParams.toString()}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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: 10,
|
|
||||||
},
|
|
||||||
route: "/oauth/providers",
|
|
||||||
});
|
|
||||||
|
|
||||||
export default (app: Hono) =>
|
|
||||||
app.on(meta.allowedMethods, meta.route, async () => {
|
|
||||||
return jsonResponse(
|
|
||||||
config.oidc.providers.map((p) => ({
|
|
||||||
name: p.name,
|
|
||||||
icon: p.icon,
|
|
||||||
id: p.id,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -52,7 +52,7 @@ const returnError = (query: object, error: string, description: string) => {
|
||||||
searchParams.append("error_description", description);
|
searchParams.append("error_description", description);
|
||||||
|
|
||||||
return response(null, 302, {
|
return response(null, 302, {
|
||||||
Location: `/oauth/authorize?${searchParams.toString()}`,
|
Location: `${config.frontend.routes.login}?${searchParams.toString()}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -177,7 +177,7 @@ export default (app: Hono) =>
|
||||||
// Redirect back to application
|
// Redirect back to application
|
||||||
return response(null, 302, {
|
return response(null, 302, {
|
||||||
Location: new URL(
|
Location: new URL(
|
||||||
`/oauth/consent?${new URLSearchParams({
|
`${config.frontend.routes.consent}?${new URLSearchParams({
|
||||||
redirect_uri: flow.application.redirectUri,
|
redirect_uri: flow.application.redirectUri,
|
||||||
code,
|
code,
|
||||||
client_id: flow.application.clientId,
|
client_id: flow.application.clientId,
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ const returnError = (query: object, error: string, description: string) => {
|
||||||
searchParams.append("error_description", description);
|
searchParams.append("error_description", description);
|
||||||
|
|
||||||
return response(null, 302, {
|
return response(null, 302, {
|
||||||
Location: `/oauth/authorize?${searchParams.toString()}`,
|
Location: `${config.frontend.routes.login}?${searchParams.toString()}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue