From b3554c13109dd25f0aa0ba4466fb1731c70e1aca Mon Sep 17 00:00:00 2001 From: Guillermo Pages Date: Sun, 5 Oct 2025 13:38:47 +0200 Subject: [PATCH] docs: clarify DAT usage and middleware configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated middleware example to use 'global' for OIDC routes. Noted that ID token verification is distinct from DAT minting. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 17 +++--- STEP_01.md | 96 --------------------------------- STEP_02.md | 72 ------------------------- STEP_03.md | 154 ----------------------------------------------------- STEP_04.md | 70 ------------------------ 5 files changed, 8 insertions(+), 401 deletions(-) delete mode 100644 STEP_01.md delete mode 100644 STEP_02.md delete mode 100644 STEP_03.md delete mode 100644 STEP_04.md diff --git a/README.md b/README.md index 59ce5de..3f2f803 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ SwissOID authentication backend for Node.js applications. Provides reusable OIDC - πŸ” Full OIDC Authorization Code Flow implementation - πŸͺ Session management with Redis -- πŸ”‘ JWT verification with JWKS +- πŸ”‘ JWT verification with JWKS (for validating SwissOID ID tokens and optional DATs) - 🎯 Built for di-why dependency injection - πŸ“¦ Reusable authentication components - πŸ”„ Configurable via environment variables or appConfig @@ -27,7 +27,7 @@ npm install swissoid-back ### With express-knifey / graphql-knifey (Recommended) -swissoid-back integrates seamlessly with express-knifey's middleware system: +`swissoid-back` integrates with `express-knifey` middleware handles so gateways/standalone services can share the same DI wiring used across Cronide: ```typescript import DiContainer, { mergeLDs } from 'di-why'; @@ -36,16 +36,15 @@ import { swissoidAuthLoadDict } from 'swissoid-back'; // Define middleware configuration including OIDC routes const middlewareConfig = { - '/graphql': [ + global: [ { name: 'expressCorsMiddleware', priority: 90 }, { name: 'expressCookieParserMiddleware', priority: 80 }, { name: 'expressBodyParserMiddleware', priority: 70 }, + { name: 'oidcStandardRoutesMiddleware', priority: 60 }, + ], + '/graphql': [ { name: 'expressGraphqlMiddleware', required: true, priority: -100 }, ], - '*': [ - // Add OIDC routes as global middleware - { name: 'oidcStandardRoutesMiddleware', priority: 50 }, - ] }; const diContainer = new DiContainer({ @@ -117,7 +116,7 @@ The package provides the following OIDC routes when loaded: - `GET /login` - Initiates OIDC authorization flow - `POST /oidc/callback` - Handles OIDC callback from SwissOID -- `POST /oidc/finalize` - Completes authentication and sets session +- `POST /oidc/finalize` - Completes authentication and sets session (gateway subsequently mints a DAT for subgraphs) - `GET /auth/status` - Returns current authentication status - `POST /auth/logout` - Logs out the user @@ -152,4 +151,4 @@ import { ## License -MIT \ No newline at end of file +MIT diff --git a/STEP_01.md b/STEP_01.md deleted file mode 100644 index 546d01c..0000000 --- a/STEP_01.md +++ /dev/null @@ -1,96 +0,0 @@ -# STEP 01 – Wire project dependencies and environment for swissoid-back - -Updated `biblio-stats-graphql` to depend on `swissoid-back` and its runtime requirements (express-knifey, ioredis, redis) while aligning Express to v4 for compatibility. Also documented the new SwissOID and Redis environment variables the service must expose. - -```diff ---- a/package.json -+++ b/package.json -@@ -- "express": "^5.1.0", -- "ghooks": "^2.0.4", -- "graphql-knifey": "^7.1.2", -- "jose": "^6.1.0", -- "mysql-oh-wait-utils": "^0.5.1", -- "saylo": "0.6.3", -- "swiss-army-knifey": "^1.36.4", -- "typescript": "5.9.2", -- "uuid": "^13.0.0" -+ "express": "^4.21.2", -+ "express-knifey": "^1.1.2", -+ "ghooks": "^2.0.4", -+ "graphql-knifey": "^7.1.2", -+ "ioredis": "^5.7.0", -+ "mysql-oh-wait-utils": "^0.5.1", -+ "redis": "^5.8.2", -+ "saylo": "0.6.3", -+ "swiss-army-knifey": "^1.36.4", -+ "swissoid-back": "^2.2.1", -+ "typescript": "5.9.2", -+ "uuid": "^13.0.0" -``` - -```diff ---- a/.env -+++ b/.env -@@ --# SwissOID Configuration --SWISSOID_CLIENT_ID=biblio-stats --SWISSOID_ISSUER=https://api.swissoid.com --SWISSOID_JWKS_URI=https://api.swissoid.com/.well-known/jwks.json --ACCESS_TOKEN_COOKIE=swissoid_access_token --REFRESH_TOKEN_COOKIE=swissoid_refresh_token --COOKIE_DOMAIN=.biblio-stats.meow.ch -+# SwissOID Configuration for swissoid-back -+SWISSOID_CLIENT_ID=biblio-stats -+SWISSOID_ISSUER=https://api.swissoid.com -+SWISSOID_JWKS_URI=https://api.swissoid.com/.well-known/jwks.json -+SWISSOID_TOKEN_ENDPOINT=https://api.swissoid.com/token -+SWISSOID_AUTHORIZE_ENDPOINT=https://api.swissoid.com/authorize -+ -+# Relying party (this service) OIDC + session configuration -+OIDC_REDIRECT_BASE_URL=http://localhost:3666 -+RP_FRONTEND_URL=http://localhost:5173 -+RP_COOKIE_DOMAIN=.biblio-stats.meow.ch -+SESSION_COOKIE_NAME=biblio_stats_session -+REFRESH_COOKIE_NAME=biblio_stats_refresh -+SESSION_SECRET=dev-super-secret-change-me -+STATE_SIGNING_SECRET=dev-super-secret-change-me-state -+SESSION_TTL=7200 -+REFRESH_TTL=604800 -+ -+# Redis connection for session storage -+REDIS_URL=redis://localhost:6379 -``` - -```diff ---- a/.env.prod -+++ b/.env.prod -@@ --# SwissOID Configuration --SWISSOID_CLIENT_ID=biblio-stats --SWISSOID_ISSUER=https://api.swissoid.com --SWISSOID_JWKS_URI=https://api.swissoid.com/.well-known/jwks.json --ACCESS_TOKEN_COOKIE=swissoid_access_token --REFRESH_TOKEN_COOKIE=swissoid_refresh_token --COOKIE_DOMAIN=.biblio-stats.meow.ch -+# SwissOID Configuration for swissoid-back -+SWISSOID_CLIENT_ID=biblio-stats -+SWISSOID_ISSUER=https://api.swissoid.com -+SWISSOID_JWKS_URI=https://api.swissoid.com/.well-known/jwks.json -+SWISSOID_TOKEN_ENDPOINT=https://api.swissoid.com/token -+SWISSOID_AUTHORIZE_ENDPOINT=https://api.swissoid.com/authorize -+ -+# Relying party (this service) OIDC + session configuration -+OIDC_REDIRECT_BASE_URL=https://graphql.biblio-stats.meow.ch -+RP_FRONTEND_URL=https://biblio-stats.meow.ch -+RP_COOKIE_DOMAIN=.biblio-stats.meow.ch -+SESSION_COOKIE_NAME=biblio_stats_session -+REFRESH_COOKIE_NAME=biblio_stats_refresh -+SESSION_SECRET=prod-super-secret-change-me -+STATE_SIGNING_SECRET=prod-super-secret-change-me-state -+SESSION_TTL=7200 -+REFRESH_TTL=604800 -+ -+# Redis connection for session storage -+REDIS_URL=redis://redis:6379 -``` diff --git a/STEP_02.md b/STEP_02.md deleted file mode 100644 index 2da26f5..0000000 --- a/STEP_02.md +++ /dev/null @@ -1,72 +0,0 @@ -# STEP 02 – Replace local auth wiring with swissoid-back loaders - -Integrated the shared `swissoidAuthLoadDict` and express middleware handles, rebuilding the DI container so the service mounts SwissOID routes through express-knifey. This removes the bespoke loaders and ensures session/cookie services are injected into the Apollo context. - -## Loader rewrite (`biblio-stats-graphql/src/loaders/index.ts`) -```ts -import 'dotenv/config'; - -import statModel from './statsModel'; -import statService from './statsService'; -import userModel from './userModel'; -import userService from './userService'; -import localAppConfigMap from '../config/appConfig'; -import { mysqlReqLoader as mysqlReq, mysqlMultipleReqLoader as mysqlMultipleReq } from 'mysql-oh-wait-utils'; -import resolvers from '../graphql/resolvers'; -import graphqlSchema from '../graphql/schema'; -import coreToDomain from './coreToDomain'; -import logger from './logger'; - -import DiContainer, { addMergeableConfigMap, AppConfigNamespace, LoadDict, mergeLDs } from 'di-why'; -import { apolloContextLDEGen, apolloStandaloneServerModularLDGen, createGraphqlMiddlewareConfig } from 'graphql-knifey'; -import { swissoidAuthLoadDict, SWISSOID_MIDDLEWARE } from 'swissoid-back'; - -const appConfigMapNamespace: AppConfigNamespace = { - namespace: 'biblioStatsAppConfigMap', - priority: 61, -}; - -const middlewareConfig = createGraphqlMiddlewareConfig({ - global: [SWISSOID_MIDDLEWARE.oidcStandardRoutes], -}); - -const loadDict: LoadDict = mergeLDs( - swissoidAuthLoadDict, - apolloStandaloneServerModularLDGen({ - resolvers, - typeDefs: graphqlSchema, - middlewareConfig, - }), - { - mysqlReq, - mysqlMultipleReq, - logger, - statModel, - statService, - coreToDomain, - userModel, - userService, - apolloContext: apolloContextLDEGen({ - statService: 'statService', - userService: 'userService', - coreToDomain: 'coreToDomain', - sessionService: 'sessionService', - cookieManager: 'cookieManager', - appConfig: 'appConfig', - logger: 'logger', - }), - ...addMergeableConfigMap(localAppConfigMap, appConfigMapNamespace), - } -); - -const diContainer = new DiContainer({ load: loadDict }); - -export default diContainer; -``` - -## Express launcher (`biblio-stats-graphql/src/index.ts`) -```ts -await di.load('expressLauncher'); -``` - -Deleted the redundant SwissOID stubs (`src/loaders/tokenAuthService.ts`, `src/loaders/swissoidAuthMiddleware.ts`, `src/loaders/swissoidOidcRoutes.ts`, `src/loaders/apolloContextWithSwissoidAuth.ts`, `src/middleware/swissoidAuth.ts`, `src/setupAuth.ts`) since the shared package now owns the flow. diff --git a/STEP_03.md b/STEP_03.md deleted file mode 100644 index 24a0096..0000000 --- a/STEP_03.md +++ /dev/null @@ -1,154 +0,0 @@ -# STEP 03 – Adapt GraphQL context, authorization, and docs to SwissOID sessions - -Aligned the GraphQL layer with the session-based flow exposed by `swissoid-back`, eliminating the old JWT token helpers and ensuring resolvers consume the shared session service. - -## Updated types (`biblio-stats-graphql/src/generalTypes.ts`) -```ts -import { ActionOutcomeError, ActionOutcomeFail, ActionOutcomeSuccess, ActionStatus, UUIDProp } from 'graphql-knifey'; -import type { Request } from 'express'; -import type { CookieManager, SwissOIDSessionService } from 'swissoid-back'; - -import UserService from './services/UserService'; -import StatService from './services/StatService'; -import type { AppConfig } from './config/appConfig'; - -export type BiblioStatsGraphQLContext = { - statService: StatService; - userService: UserService; - coreToDomain: (core: string) => string; - sessionService?: SwissOIDSessionService; - cookieManager?: CookieManager; - appConfig?: AppConfig; - logger?: { log: (...args: any[]) => void }; - token?: string; - req?: Request & { cookies?: Record }; - user?: { - UUID?: string; - email?: string; - username?: string; - sub?: string; - }; -}; -``` - -## Session-backed authorization (`biblio-stats-graphql/src/utils/authorize.ts`) -```ts -import type { SessionData } from 'swissoid-back'; - -import { BiblioStatsGraphQLContext, UserProp } from '../generalTypes'; -import { getFailOutcomeFromError } from 'graphql-knifey'; -import { ActionOutcomeFail, ActionOutcomeForbidden, ActionStatus, GqlResolversContextParams } from 'graphql-knifey/build/src/generalTypes'; - -const unauthenticatedOutcome: ActionOutcomeFail<'UNAUTHENTICATED'> = { - status: ActionStatus.fail, - code: 'UNAUTHENTICATED', - message: 'Authentication required', -}; - -function buildUserFromSession(session: SessionData) { - return { - UUID: session.sub, - email: session.email, - username: session.metadata?.username ?? session.name ?? session.email, - sub: session.sub, - }; -} - -async function resolveSession(context: GqlResolversContextParams) { - const { sessionService, cookieManager, req } = context; - if (!sessionService || !req) { - return null; - } - - const cookieName = context.appConfig?.sessionCookieName || 'sid'; - let sessionId = cookieManager?.getSessionId?.(req) || req.cookies?.[cookieName]; - - if (!sessionId) { - const rawCookies = req.headers?.cookie ?? req.headers?.Cookie; - const cookieHeader = Array.isArray(rawCookies) ? rawCookies.join(';') : rawCookies; - - if (cookieHeader) { - sessionId = cookieHeader - .split(';') - .map((cookie) => cookie.trim().split('=') as [string, string]) - .find(([name]) => name === cookieName)?.[1]; - } - } - - if (!sessionId) { - return null; - } - - const session = await sessionService.getSession(sessionId); - return session ?? null; -} - -export async function authorized( - context: GqlResolversContextParams, - input: T -): Promise<{ shouldReturn: true; value: ActionOutcomeForbidden | ActionOutcomeFail<'ERROR'> | ActionOutcomeFail<'UNAUTHENTICATED'> } | { shouldReturn: false; value: UserProp & T }> -{ - try { - const session = await resolveSession(context); - - if (!session) { - return { shouldReturn: true, value: unauthenticatedOutcome }; - } - - const props = { - ...input, - user: buildUserFromSession(session), - }; - - context.user = props.user; - - const isAllowed = await context.userService.existsOrRegisterAndIfExistsIsAllowed({ user: props.user }); - if (!isAllowed) { - return { - shouldReturn: true, - value: { - status: ActionStatus.fail, - code: 'FORBIDDEN', - message: "You don't have permission to perform this request", - }, - }; - } - - return { - shouldReturn: false, - value: props, - }; - } catch (err) { - return { - shouldReturn: true, - value: getFailOutcomeFromError(err as Error), - }; - } -} -``` - -## Resolver integration (`biblio-stats-graphql/src/graphql/resolvers/index.ts`) -```ts -import { GQLResolverDict, getFailOutcomeFromError } from 'graphql-knifey'; - -// ... - -registerUser: async (_, __, context) => { - try { - const authResult = await authorized(context, {}); - if (authResult.shouldReturn) return authResult.value; - return await context.userService.registerUser(authResult.value); - } catch (err) { - return getFailOutcomeFromError(err as Error); - } -}, -``` - -## Documentation (`biblio-stats-graphql/README.md`) -```md -Authentication is handled by the shared SwissOID backend (`swissoid-back`). The service mounts the reusable OIDC routes so `/login`, `/oidc/callback`, `/auth/status`, and `/auth/logout` follow the exact same flow as cronide-user. Sessions are stored in Redis and materialised as HttpOnly cookies on the relying party domain. - -Resolvers read the active session via `sessionService` and `cookieManager` from `swissoid-back`. When a valid session cookie is present the user UUID/email is injected into the GraphQL context and used for downstream authorization. -``` - -Legacy JWT helpers were deleted (`src/loaders/tokenAuthService.ts`, `src/middleware/swissoidAuth.ts`, `src/setupAuth.ts`, and related middleware wrappers) because the SwissOID middleware now covers the full flow. diff --git a/STEP_04.md b/STEP_04.md deleted file mode 100644 index a86b3c5..0000000 --- a/STEP_04.md +++ /dev/null @@ -1,70 +0,0 @@ -# STEP 04 – Provide Redis runtime for SwissOID sessions - -Added a Redis service to biblio-stats-graphql’s docker compose files so swissoid-back’s session store has a local endpoint, and wired the required environment variables through the containers. - -## docker-compose.dev.yml -```yaml -services: - redis: - image: redis:7-alpine - container_name: "${APP_DIRNAME}_redis" - command: ["redis-server", "--save", "", "--appendonly", "no"] - restart: always - - garphql-server-stats: - environment: - LOGGER_DEBUG: "${LOGGER_DEBUG}" - SWISSOID_CLIENT_ID: "${SWISSOID_CLIENT_ID}" - SWISSOID_ISSUER: "${SWISSOID_ISSUER}" - SWISSOID_JWKS_URI: "${SWISSOID_JWKS_URI}" - SWISSOID_TOKEN_ENDPOINT: "${SWISSOID_TOKEN_ENDPOINT}" - SWISSOID_AUTHORIZE_ENDPOINT: "${SWISSOID_AUTHORIZE_ENDPOINT}" - OIDC_REDIRECT_BASE_URL: "${OIDC_REDIRECT_BASE_URL}" - RP_FRONTEND_URL: "${RP_FRONTEND_URL}" - RP_COOKIE_DOMAIN: "${RP_COOKIE_DOMAIN}" - SESSION_COOKIE_NAME: "${SESSION_COOKIE_NAME}" - REFRESH_COOKIE_NAME: "${REFRESH_COOKIE_NAME}" - SESSION_SECRET: "${SESSION_SECRET}" - STATE_SIGNING_SECRET: "${STATE_SIGNING_SECRET}" - SESSION_TTL: "${SESSION_TTL}" - REFRESH_TTL: "${REFRESH_TTL}" - REDIS_URL: "${REDIS_URL}" - depends_on: - - "${DB_HOST}" - - redis -``` - -## docker-compose.yml -```yaml -services: - redis: - image: redis:7-alpine - container_name: "${REVERSE_DOMAIN}_redis" - command: ["redis-server", "--save", "", "--appendonly", "no"] - restart: always - networks: - - app_network - - biblio-stats-graphql: - environment: - SWISSOID_CLIENT_ID: "${SWISSOID_CLIENT_ID}" - SWISSOID_ISSUER: "${SWISSOID_ISSUER}" - SWISSOID_JWKS_URI: "${SWISSOID_JWKS_URI}" - SWISSOID_TOKEN_ENDPOINT: "${SWISSOID_TOKEN_ENDPOINT}" - SWISSOID_AUTHORIZE_ENDPOINT: "${SWISSOID_AUTHORIZE_ENDPOINT}" - OIDC_REDIRECT_BASE_URL: "${OIDC_REDIRECT_BASE_URL}" - RP_FRONTEND_URL: "${RP_FRONTEND_URL}" - RP_COOKIE_DOMAIN: "${RP_COOKIE_DOMAIN}" - SESSION_COOKIE_NAME: "${SESSION_COOKIE_NAME}" - REFRESH_COOKIE_NAME: "${REFRESH_COOKIE_NAME}" - SESSION_SECRET: "${SESSION_SECRET}" - STATE_SIGNING_SECRET: "${STATE_SIGNING_SECRET}" - SESSION_TTL: "${SESSION_TTL}" - REFRESH_TTL: "${REFRESH_TTL}" - REDIS_URL: "${REDIS_URL}" - depends_on: - - "${DB_HOST}" - - redis -``` - -This ensures local and production stacks both ship with Redis so `swissoid-back` can persist sessions without additional manual setup.