282 lines
8.7 KiB
TypeScript
282 lines
8.7 KiB
TypeScript
/**
|
|
* OIDC Provider configuration for Ghost Guild.
|
|
*
|
|
* ghostguild.org acts as the identity provider. Outline wiki is the sole
|
|
* relying party (client). Members authenticate via the existing magic-link
|
|
* flow, and the provider issues standard OIDC tokens so Outline can identify
|
|
* them.
|
|
*/
|
|
import Provider from "oidc-provider";
|
|
import { MongoAdapter } from "./oidc-mongodb-adapter.js";
|
|
import Member from "../models/member.js";
|
|
import { connectDB } from "./mongoose.js";
|
|
|
|
/**
|
|
* Renders a standalone HTML page in the guild dark style.
|
|
* Used for OIDC logout/error screens that are served outside Nuxt.
|
|
*/
|
|
function guildPageShell(title: string, bodyContent: string, extraStyles = "") {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>${title} — Ghost Guild</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
background-color: #1a1510;
|
|
background-image:
|
|
radial-gradient(ellipse at 20% 50%, rgba(154, 111, 44, 0.06) 0%, transparent 60%),
|
|
radial-gradient(ellipse at 80% 50%, rgba(154, 111, 44, 0.04) 0%, transparent 60%);
|
|
color: #bfb3a2;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
}
|
|
.card {
|
|
background-color: #2a241c;
|
|
border: 1px solid rgba(154, 111, 44, 0.15);
|
|
border-radius: 12px;
|
|
box-shadow: 0 0 30px rgba(208, 158, 78, 0.06);
|
|
padding: 2.5rem;
|
|
width: 100%;
|
|
max-width: 420px;
|
|
text-align: center;
|
|
}
|
|
h1 {
|
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: #d09e4e;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
p { line-height: 1.6; margin-bottom: 1rem; }
|
|
.subtext { font-size: 0.875rem; color: #6b5f4d; }
|
|
.btn-primary {
|
|
display: inline-block;
|
|
background-color: #d09e4e;
|
|
color: #1a1510;
|
|
padding: 0.625rem 1.5rem;
|
|
border-radius: 6px;
|
|
border: none;
|
|
font-size: 0.875rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
transition: background-color 0.15s;
|
|
}
|
|
.btn-primary:hover { background-color: #e0b86e; }
|
|
.btn-secondary {
|
|
display: inline-block;
|
|
background-color: transparent;
|
|
color: #f0ebe4;
|
|
padding: 0.625rem 1.5rem;
|
|
border-radius: 6px;
|
|
border: 1px solid rgba(208, 158, 78, 0.4);
|
|
font-size: 0.875rem;
|
|
font-weight: 400;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
transition: border-color 0.15s, color 0.15s;
|
|
}
|
|
.btn-secondary:hover {
|
|
border-color: rgba(224, 184, 110, 0.6);
|
|
color: #f5e6c5;
|
|
}
|
|
.actions { display: flex; gap: 0.75rem; justify-content: center; margin-top: 1.5rem; }
|
|
.brand {
|
|
margin-top: 2rem;
|
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
font-size: 0.75rem;
|
|
font-variant: small-caps;
|
|
letter-spacing: 0.05em;
|
|
color: #6b5f4d;
|
|
}
|
|
.error-detail {
|
|
margin-top: 1rem;
|
|
background-color: #1a1510;
|
|
border: 1px solid rgba(154, 111, 44, 0.1);
|
|
border-radius: 6px;
|
|
padding: 1rem;
|
|
font-family: 'Ubuntu Mono', 'Courier New', monospace;
|
|
font-size: 0.75rem;
|
|
color: #6b5f4d;
|
|
text-align: left;
|
|
word-break: break-word;
|
|
}
|
|
${extraStyles}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
${bodyContent}
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
let _provider: InstanceType<typeof Provider> | null = null;
|
|
|
|
export async function getOidcProvider() {
|
|
if (_provider) return _provider;
|
|
|
|
const config = useRuntimeConfig();
|
|
const issuer = process.env.OIDC_ISSUER || "https://ghostguild.org";
|
|
|
|
_provider = new Provider(issuer, {
|
|
adapter: MongoAdapter,
|
|
|
|
// Trust X-Forwarded-Proto from Traefik reverse proxy
|
|
proxy: true,
|
|
|
|
clients: [
|
|
{
|
|
client_id: process.env.OIDC_CLIENT_ID || "outline-wiki",
|
|
client_secret: process.env.OIDC_CLIENT_SECRET || "",
|
|
redirect_uris: [
|
|
"https://wiki.ghostguild.org/auth/oidc.callback",
|
|
// Local development callback
|
|
"http://localhost:3100/auth/oidc.callback",
|
|
],
|
|
post_logout_redirect_uris: [
|
|
"https://wiki.ghostguild.org",
|
|
"http://localhost:3100",
|
|
],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "client_secret_post",
|
|
},
|
|
],
|
|
|
|
claims: {
|
|
openid: ["sub"],
|
|
profile: ["name", "preferred_username"],
|
|
email: ["email", "email_verified"],
|
|
},
|
|
|
|
scopes: ["openid", "profile", "email", "offline_access"],
|
|
|
|
findAccount: async (_ctx: unknown, id: string) => {
|
|
await connectDB();
|
|
const member = await (Member as any).findById(id);
|
|
if (!member) return undefined;
|
|
|
|
return {
|
|
accountId: id,
|
|
async claims(_use: string, _scope: string) {
|
|
return {
|
|
sub: id,
|
|
name: member.name,
|
|
preferred_username: member.name,
|
|
email: member.email,
|
|
email_verified: true,
|
|
};
|
|
},
|
|
};
|
|
},
|
|
|
|
cookies: {
|
|
keys: (process.env.OIDC_COOKIE_SECRET || "dev-cookie-secret").split(","),
|
|
},
|
|
|
|
ttl: {
|
|
AccessToken: 3600, // 1 hour
|
|
AuthorizationCode: 600, // 10 minutes
|
|
RefreshToken: 14 * 24 * 60 * 60, // 14 days
|
|
Session: 14 * 24 * 60 * 60, // 14 days
|
|
Interaction: 600, // 10 minutes
|
|
Grant: 14 * 24 * 60 * 60, // 14 days
|
|
},
|
|
|
|
features: {
|
|
devInteractions: {
|
|
enabled: process.env.NODE_ENV !== "production",
|
|
},
|
|
revocation: { enabled: true },
|
|
rpInitiatedLogout: {
|
|
enabled: true,
|
|
logoutSource: async (ctx: any, form: string) => {
|
|
// oidc-provider generates http:// form actions behind reverse proxy
|
|
const secureForm = form.replace('http://ghostguild.org', 'https://ghostguild.org');
|
|
ctx.body = guildPageShell("Sign Out", `
|
|
<h1>Sign Out</h1>
|
|
<p>Do you want to sign out of your Ghost Guild session?</p>
|
|
<p class="subtext">This will sign you out of the wiki and any other connected services.</p>
|
|
${secureForm}
|
|
<div class="actions">
|
|
<button class="btn-primary" form="op.logoutForm" type="submit" value="yes" name="logout">Yes, sign me out</button>
|
|
<a class="btn-secondary" href="https://wiki.ghostguild.org">Stay signed in</a>
|
|
</div>
|
|
`, "form#op\\.logoutForm { display: none; }");
|
|
},
|
|
postLogoutSuccessSource: async (ctx: any) => {
|
|
ctx.body = guildPageShell("Signed Out", `
|
|
<h1>Signed Out</h1>
|
|
<p>You have been successfully signed out.</p>
|
|
<div class="actions">
|
|
<a class="btn-primary" href="https://wiki.ghostguild.org">Return to Wiki</a>
|
|
</div>
|
|
`);
|
|
},
|
|
},
|
|
},
|
|
|
|
// Mount all OIDC endpoints under /oidc prefix
|
|
routes: {
|
|
authorization: "/oidc/auth",
|
|
backchannel_authentication: "/oidc/backchannel",
|
|
code_verification: "/oidc/device",
|
|
device_authorization: "/oidc/device/auth",
|
|
end_session: "/oidc/session/end",
|
|
introspection: "/oidc/token/introspection",
|
|
jwks: "/oidc/jwks",
|
|
pushed_authorization_request: "/oidc/request",
|
|
registration: "/oidc/reg",
|
|
revocation: "/oidc/token/revocation",
|
|
token: "/oidc/token",
|
|
userinfo: "/oidc/me",
|
|
},
|
|
|
|
interactions: {
|
|
url(_ctx: unknown, interaction: { uid: string }) {
|
|
return `/oidc/interaction/${interaction.uid}`;
|
|
},
|
|
},
|
|
|
|
renderError: async (ctx: any, out: Record<string, string>, _error: Error) => {
|
|
const details = Object.entries(out)
|
|
.map(([key, value]) => `<strong>${key}:</strong> ${value}`)
|
|
.join("<br>");
|
|
ctx.body = guildPageShell("Something Went Wrong", `
|
|
<h1>Something Went Wrong</h1>
|
|
<p>An error occurred during authentication. Please try again.</p>
|
|
<div class="error-detail">${details}</div>
|
|
<div class="actions">
|
|
<a class="btn-primary" href="https://wiki.ghostguild.org">Return to Wiki</a>
|
|
</div>
|
|
`);
|
|
},
|
|
|
|
// Allow Outline to use PKCE but don't require it
|
|
pkce: {
|
|
required: () => false,
|
|
},
|
|
|
|
// Skip consent for our first-party Outline client
|
|
loadExistingGrant: async (ctx: any) => {
|
|
const grant = new (ctx.oidc.provider.Grant as any)({
|
|
accountId: ctx.oidc.session!.accountId,
|
|
clientId: ctx.oidc.client!.clientId,
|
|
});
|
|
grant.addOIDCScope("openid profile email");
|
|
await grant.save();
|
|
return grant;
|
|
},
|
|
});
|
|
|
|
return _provider;
|
|
}
|