feat(config): Allow frontend route customization and forcing OIDC

This commit is contained in:
Jesse Wierzbinski 2024-05-16 18:05:06 -10:00
parent b34166de93
commit 2db4f25ba6
No known key found for this signature in database
11 changed files with 108 additions and 59 deletions

View file

@ -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).

View file

@ -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.

View file

@ -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

View file

@ -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",

View file

@ -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",
})}`, })}`,
}); });

View file

@ -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

View file

@ -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()}`,

View file

@ -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()}`,
}); });
}; };

View file

@ -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,
})),
);
});

View file

@ -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,

View file

@ -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()}`,
}); });
}; };