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
|
||||
|
||||
- [x] Federation (partial)
|
||||
- [x] Fully compliant Lysand 3.0 federation (partial)
|
||||
- [x] Hyper fast (thousands of HTTP requests per second)
|
||||
- [x] S3 or local media storage
|
||||
- [x] Deduplication of uploaded files
|
||||
|
|
@ -32,11 +32,21 @@
|
|||
- [x] Configurable defaults
|
||||
- [x] Full regex-based filters for posts, users and media
|
||||
- [x] Custom emoji support
|
||||
- [x] Users can upload their own emojis for themselves
|
||||
- [x] Automatic image conversion to WebP or other formats
|
||||
- [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.
|
||||
- [ ] 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
|
||||
|
||||
|
|
@ -174,7 +184,7 @@ Working endpoints are:
|
|||
- [ ] `/oauth/revoke`
|
||||
- Admin API
|
||||
|
||||
### Main work to do
|
||||
### Main work to do for API
|
||||
|
||||
- [ ] Announcements
|
||||
- [ ] Polls
|
||||
|
|
@ -190,6 +200,10 @@ Working endpoints are:
|
|||
- [ ] Reports
|
||||
- [ ] Admin API
|
||||
|
||||
## Lysand API
|
||||
|
||||
For Lysand's own custom API, please see the [API documentation](docs/api/index.md).
|
||||
|
||||
## 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
|
||||
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
|
||||
# This is an example configuration
|
||||
# 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)
|
||||
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]
|
||||
# Arbitrary key/value pairs to be passed to the frontend
|
||||
# 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
|
||||
|
||||
- `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 Frontend Configuration
|
||||
|
|
|
|||
|
|
@ -6,6 +6,20 @@ export enum MediaBackendType {
|
|||
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({
|
||||
database: z.object({
|
||||
host: z.string().min(1).default("localhost"),
|
||||
|
|
@ -78,6 +92,7 @@ export const configValidator = z.object({
|
|||
rules: z.array(z.string()).default([]),
|
||||
}),
|
||||
oidc: z.object({
|
||||
forced: z.boolean().default(false),
|
||||
providers: z
|
||||
.array(
|
||||
z.object({
|
||||
|
|
@ -136,18 +151,31 @@ export const configValidator = z.object({
|
|||
frontend: z
|
||||
.object({
|
||||
enabled: z.boolean().default(true),
|
||||
url: z.string().min(1).url().default("http://localhost:3000"),
|
||||
url: zUrl.default("http://localhost:3000"),
|
||||
glitch: z
|
||||
.object({
|
||||
enabled: z.boolean().default(false),
|
||||
assets: z.string().min(1).default("glitch"),
|
||||
server: z.array(z.string().url().min(1)).default([]),
|
||||
server: z.array(zUrl).default([]),
|
||||
})
|
||||
.default({
|
||||
enabled: false,
|
||||
assets: "glitch",
|
||||
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({}),
|
||||
})
|
||||
.default({
|
||||
|
|
@ -215,7 +243,7 @@ export const configValidator = z.object({
|
|||
secret_access_key: z.string(),
|
||||
region: z.string().optional(),
|
||||
bucket_name: z.string().default("lysand"),
|
||||
public_url: z.string().url(),
|
||||
public_url: zUrl,
|
||||
})
|
||||
.default({
|
||||
endpoint: "",
|
||||
|
|
@ -374,8 +402,8 @@ export const configValidator = z.object({
|
|||
.object({
|
||||
visibility: z.string().default("public"),
|
||||
language: z.string().default("en"),
|
||||
avatar: z.string().url().optional(),
|
||||
header: z.string().url().optional(),
|
||||
avatar: zUrl.optional(),
|
||||
header: zUrl.optional(),
|
||||
placeholder_style: z.string().default("thumbs"),
|
||||
})
|
||||
.default({
|
||||
|
|
@ -387,18 +415,18 @@ export const configValidator = z.object({
|
|||
}),
|
||||
federation: z
|
||||
.object({
|
||||
blocked: z.array(z.string().url()).default([]),
|
||||
followers_only: z.array(z.string().url()).default([]),
|
||||
blocked: z.array(zUrl).default([]),
|
||||
followers_only: z.array(zUrl).default([]),
|
||||
discard: z.object({
|
||||
reports: z.array(z.string().url()).default([]),
|
||||
deletes: z.array(z.string().url()).default([]),
|
||||
updates: z.array(z.string().url()).default([]),
|
||||
media: z.array(z.string().url()).default([]),
|
||||
follows: z.array(z.string().url()).default([]),
|
||||
likes: z.array(z.string().url()).default([]),
|
||||
reactions: z.array(z.string().url()).default([]),
|
||||
banners: z.array(z.string().url()).default([]),
|
||||
avatars: z.array(z.string().url()).default([]),
|
||||
reports: z.array(zUrl).default([]),
|
||||
deletes: z.array(zUrl).default([]),
|
||||
updates: z.array(zUrl).default([]),
|
||||
media: z.array(zUrl).default([]),
|
||||
follows: z.array(zUrl).default([]),
|
||||
likes: z.array(zUrl).default([]),
|
||||
reactions: z.array(zUrl).default([]),
|
||||
banners: z.array(zUrl).default([]),
|
||||
avatars: z.array(zUrl).default([]),
|
||||
}),
|
||||
})
|
||||
.default({
|
||||
|
|
@ -421,8 +449,8 @@ export const configValidator = z.object({
|
|||
name: z.string().min(1).default("Lysand"),
|
||||
description: z.string().min(1).default("A Lysand instance"),
|
||||
extended_description_path: z.string().optional(),
|
||||
logo: z.string().url().optional(),
|
||||
banner: z.string().url().optional(),
|
||||
logo: zUrl.optional(),
|
||||
banner: zUrl.optional(),
|
||||
})
|
||||
.default({
|
||||
name: "Lysand",
|
||||
|
|
|
|||
|
|
@ -152,7 +152,9 @@ export class OAuthManager {
|
|||
// Check if userId is equal to application.clientId
|
||||
if ((flow.application?.clientId ?? "") !== userId) {
|
||||
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_message: `User ID does not match application client ID (${userId} != ${flow.application?.clientId})`,
|
||||
})}`,
|
||||
|
|
@ -170,7 +172,9 @@ export class OAuthManager {
|
|||
|
||||
if (account) {
|
||||
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_message:
|
||||
"This account has already been linked to this OpenID Connect provider.",
|
||||
|
|
@ -186,7 +190,9 @@ export class OAuthManager {
|
|||
});
|
||||
|
||||
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",
|
||||
})}`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ const returnError = (query: object, error: string, description: string) => {
|
|||
|
||||
return response(null, 302, {
|
||||
Location: new URL(
|
||||
`/oauth/authorize?${searchParams.toString()}`,
|
||||
`${config.frontend.routes.login}?${searchParams.toString()}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
});
|
||||
|
|
@ -87,6 +87,14 @@ export default (app: Hono) =>
|
|||
zValidator("form", schemas.form, handleZodError),
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
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 { client_id } = context.req.valid("query");
|
||||
|
||||
|
|
@ -160,7 +168,9 @@ export default (app: Hono) =>
|
|||
// Redirect to OAuth authorize with JWT
|
||||
return response(null, 302, {
|
||||
Location: new URL(
|
||||
`/oauth/consent?${searchParams.toString()}`,
|
||||
`${
|
||||
config.frontend.routes.consent
|
||||
}?${searchParams.toString()}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
// Set cookie with JWT
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { Hono } from "hono";
|
|||
import { z } from "zod";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Applications, Tokens } from "~drizzle/schema";
|
||||
import { config } from "~packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
|
|
@ -40,7 +41,7 @@ export default (app: Hono) =>
|
|||
|
||||
const redirectToLogin = (error: string) =>
|
||||
Response.redirect(
|
||||
`/oauth/authorize?${new URLSearchParams({
|
||||
`${config.frontend.routes.login}?${new URLSearchParams({
|
||||
...context.req.query,
|
||||
error: encodeURIComponent(error),
|
||||
}).toString()}`,
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ const returnError = (query: object, error: string, description: string) => {
|
|||
searchParams.append("error_description", description);
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
return response(null, 302, {
|
||||
Location: new URL(
|
||||
`/oauth/consent?${new URLSearchParams({
|
||||
`${config.frontend.routes.consent}?${new URLSearchParams({
|
||||
redirect_uri: flow.application.redirectUri,
|
||||
code,
|
||||
client_id: flow.application.clientId,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const returnError = (query: object, error: string, description: string) => {
|
|||
searchParams.append("error_description", description);
|
||||
|
||||
return response(null, 302, {
|
||||
Location: `/oauth/authorize?${searchParams.toString()}`,
|
||||
Location: `${config.frontend.routes.login}?${searchParams.toString()}`,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue