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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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