diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..a51ba38 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,35 @@ +services: + traefik: + image: traefik:latest + command: + - --log.level=INFO + - --accesslog=true + - --api.dashboard=true + - --providers.docker=true + - --entrypoints.web.address=:80 + ports: + - "8080:80/tcp" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + labels: + - traefik.http.routers.traefik.rule=Host(`traefik.localhost`) + - traefik.http.routers.traefik.service=api@internal + + nanaoidc: + image: node:lts-alpine + command: sleep infinity + working_dir: /app + volumes: + - .:/app + labels: + - traefik.http.routers.nanaoidc.rule=Host(`nanaoidc.localhost`) || Path(`/_oauth`) + - traefik.http.services.nanaoidc.loadbalancer.server.port=3000 + - traefik.http.middlewares.nanaoidc.forwardAuth.address=http://nanaoidc:3000/traefik + - traefik.http.middlewares.nanaoidc.forwardAuth.trustForwardHeader=true + + whoami: + image: traefik/whoami:latest + labels: + - traefik.http.routers.whoami.rule=Host(`whoami.localhost`) + - traefik.http.routers.whoami.middlewares=nanaoidc + - traefik.http.services.whoami.loadbalancer.server.port=80 diff --git a/nanaoidc.example.json b/nanaoidc.example.json index cb12090..ddca677 100644 --- a/nanaoidc.example.json +++ b/nanaoidc.example.json @@ -1,6 +1,6 @@ { - "publicUrl": "", - "sessionPassword": "", + "publicUrl": "http://localhost:3000", + "sessionPassword": "openssl rand -hex 32", "baseGroup": "", "discord": { "clientId": "", @@ -10,6 +10,11 @@ "1234567890": "private-group" } }, + "forwardAuth": { + "hosts": { + "sub.localhost": ["group1", "orGroup2"] + } + }, "clients": [ { "client_id": "client", diff --git a/server/api/discord/login.get.ts b/server/api/discord/auth.get.ts similarity index 100% rename from server/api/discord/login.get.ts rename to server/api/discord/auth.get.ts diff --git a/server/api/discord/callback.get.ts b/server/api/discord/callback.get.ts index b12440f..e6db391 100644 --- a/server/api/discord/callback.get.ts +++ b/server/api/discord/callback.get.ts @@ -1,13 +1,12 @@ import assert from "node:assert/strict"; export default eventHandler(async (event) => { - const session = await useTypedSession(event); - const query = getQuery(event); const { code } = query; assert(typeof code === "string"); - await session.update({ code }); + const session = await useTypedSession(event); const redirect = session.data.redirect || "/"; - return sendRedirect(event, redirect); + const params = new URLSearchParams({ code }); + return sendRedirect(event, `${redirect}?${params}`); }); diff --git a/server/routes/_oauth.get.ts b/server/routes/_oauth.get.ts new file mode 100644 index 0000000..0723a2b --- /dev/null +++ b/server/routes/_oauth.get.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; + +export default eventHandler(async (event) => { + const session = await useTypedSession(event); + const query = getQuery(event); + const { code, forwardAuthRedirect } = query; + if (forwardAuthRedirect) { + assert(typeof forwardAuthRedirect === "string"); + await session.update({ redirect: ".", forwardAuthRedirect }); + return sendRedirect(event, `${userConfig.publicUrl}/api/discord/auth`); + } else if (code) { + assert(typeof code === "string"); + const resp = await exchangeCode(code); + const { member } = await fetchUserinfo(resp.access_token); + const groups = [userConfig.baseGroup]; + for (const role of member.roles) { + const mapped = userConfig.discord.roles[role]; + if (mapped) { + groups.push(mapped); + } + } + await session.update({ forwardAuthGroups: groups }); + return sendRedirect(event, session.data.forwardAuthRedirect); + } else { + throw createError({ status: 400, message: "Missing required query" }); + } +}); diff --git a/server/routes/interaction/[uid]/callback.get.ts b/server/routes/interaction/[uid]/callback.get.ts index 783758e..9693276 100644 --- a/server/routes/interaction/[uid]/callback.get.ts +++ b/server/routes/interaction/[uid]/callback.get.ts @@ -1,23 +1,13 @@ -import { REST } from "@discordjs/rest"; -import { - Routes, - type RESTGetAPICurrentUserResult, - type RESTGetCurrentUserGuildMemberResult, -} from "discord-api-types/v10"; +import assert from "node:assert/strict"; import type { InteractionResults } from "oidc-provider"; export default eventHandler(async (event) => { - const session = await useTypedSession(event); + const query = getQuery(event); + const { code } = query; + assert(typeof code === "string"); - const resp = await exchangeCode(session.data.code); - const rest = new REST({ authPrefix: "Bearer" }).setToken(resp.access_token); - const [user, member] = (await Promise.all([ - rest.get(Routes.user()), - rest.get(Routes.userGuildMember(userConfig.discord.guildId)), - ])) as [ - user: RESTGetAPICurrentUserResult, - member: RESTGetCurrentUserGuildMemberResult - ]; + const resp = await exchangeCode(code); + const { user, member } = await fetchUserinfo(resp.access_token); userStore.set(user.id, { user, member }); const result: InteractionResults = { diff --git a/server/routes/interaction/[uid]/login.get.ts b/server/routes/interaction/[uid]/login.get.ts index a72f09a..6c2a56e 100644 --- a/server/routes/interaction/[uid]/login.get.ts +++ b/server/routes/interaction/[uid]/login.get.ts @@ -8,5 +8,5 @@ export default eventHandler(async (event) => { const session = await useTypedSession(event); await session.update({ redirect: `${event.path}/../callback` }); - return sendRedirect(event, "/api/discord/login"); + return sendRedirect(event, "/api/discord/auth"); }); diff --git a/server/routes/traefik.get.ts b/server/routes/traefik.get.ts new file mode 100644 index 0000000..8724aa0 --- /dev/null +++ b/server/routes/traefik.get.ts @@ -0,0 +1,23 @@ +export default eventHandler(async (event) => { + const headers = event.headers; + const proto = headers.get("x-forwarded-proto"); + const host = headers.get("x-forwarded-host"); + const uri = headers.get("x-forwarded-uri"); + + const session = await useTypedSession(event); + const groups = session.data.forwardAuthGroups; + + if (groups !== undefined) { + const required = userConfig.forwardAuth.hosts[host]; + if (!required || required.some((req) => groups.includes(req))) { + return; + } else { + await session.clear(); + throw createError({ status: 401, message: "Missing required group" }); + } + } else { + const forwardAuthRedirect = `${proto}://${host}${uri}`; + const params = new URLSearchParams({ forwardAuthRedirect }); + return sendRedirect(event, `http://${host}/_oauth?${params}`); + } +}); diff --git a/server/utils/config.ts b/server/utils/config.ts index cfcfe28..96f798d 100644 --- a/server/utils/config.ts +++ b/server/utils/config.ts @@ -12,6 +12,9 @@ export interface UserConfig { guildId: Snowflake; roles: Record; }; + forwardAuth: { + hosts: Record; + }; clients: ClientMetadata[]; } diff --git a/server/utils/discord.ts b/server/utils/discord.ts index 4c64cec..1388cc2 100644 --- a/server/utils/discord.ts +++ b/server/utils/discord.ts @@ -1,4 +1,10 @@ -import { RESTPostOAuth2AccessTokenResult } from "discord-api-types/v10"; +import { REST } from "@discordjs/rest"; +import { + RESTPostOAuth2AccessTokenResult, + Routes, + type RESTGetAPICurrentUserResult, + type RESTGetCurrentUserGuildMemberResult, +} from "discord-api-types/v10"; const endpoints = { authorization: "https://discord.com/oauth2/authorize", @@ -34,3 +40,12 @@ export async function exchangeCode( }); return await response.json(); } + +export async function fetchUserinfo(accessToken: string) { + const rest = new REST({ authPrefix: "Bearer" }).setToken(accessToken); + const [user, member] = (await Promise.all([ + rest.get(Routes.user()), + rest.get(Routes.userGuildMember(userConfig.discord.guildId)), + ])) as [RESTGetAPICurrentUserResult, RESTGetCurrentUserGuildMemberResult]; + return { user, member }; +} diff --git a/server/utils/session.ts b/server/utils/session.ts index 87055a4..c3b3379 100644 --- a/server/utils/session.ts +++ b/server/utils/session.ts @@ -1,9 +1,13 @@ import { type H3Event } from "h3"; interface SessionData { - code: string; redirect: string; + forwardAuthRedirect: string; + forwardAuthGroups: string[]; } export const useTypedSession = (event: H3Event) => - useSession(event, { password: userConfig.sessionPassword }); + useSession(event, { + password: userConfig.sessionPassword, + maxAge: 60 * 60 * 24, + });