diff --git a/STEP_01.md b/STEP_01.md new file mode 100644 index 0000000..546d01c --- /dev/null +++ b/STEP_01.md @@ -0,0 +1,96 @@ +# 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 new file mode 100644 index 0000000..2da26f5 --- /dev/null +++ b/STEP_02.md @@ -0,0 +1,72 @@ +# 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 new file mode 100644 index 0000000..24a0096 --- /dev/null +++ b/STEP_03.md @@ -0,0 +1,154 @@ +# 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 new file mode 100644 index 0000000..a86b3c5 --- /dev/null +++ b/STEP_04.md @@ -0,0 +1,70 @@ +# 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. diff --git a/src/config/appConfigMap.ts b/src/config/appConfigMap.ts index 97055e5..471a6fb 100644 --- a/src/config/appConfigMap.ts +++ b/src/config/appConfigMap.ts @@ -30,6 +30,10 @@ const appConfigMap = function (env: { REFRESH_COOKIE_NAME?: string; SESSION_TTL?: string; REFRESH_TTL?: string; + + // Post-login redirect configuration + POST_LOGIN_PATH?: string; + ALLOW_CONTINUE_PARAM?: string; }) { // SwissOID configuration validation if (undefined === env.SWISSOID_ISSUER) { @@ -100,6 +104,10 @@ const appConfigMap = function (env: { refreshCookieName: env.REFRESH_COOKIE_NAME || 'rid', sessionTTL: env.SESSION_TTL ? parseInt(env.SESSION_TTL) : 7200, refreshTTL: env.REFRESH_TTL ? parseInt(env.REFRESH_TTL) : 604800, + + // Post-login redirect configuration + postLoginPath: env.POST_LOGIN_PATH || '/', + allowContinueParam: env.ALLOW_CONTINUE_PARAM !== 'false', // default true, set to 'false' to disable }; } diff --git a/src/oidc/oidcConfigBuilder.ts b/src/oidc/oidcConfigBuilder.ts index 22b249c..5364bcd 100644 --- a/src/oidc/oidcConfigBuilder.ts +++ b/src/oidc/oidcConfigBuilder.ts @@ -27,6 +27,10 @@ export function buildOidcConfig(appConfig: SwissoidAppConfig, redisClient: Redis sessionSecret: appConfig.sessionSecret, // State signing secret - stateSigningSecret: appConfig.stateSigningSecret || appConfig.sessionSecret + '-state-signing' + stateSigningSecret: appConfig.stateSigningSecret || appConfig.sessionSecret + '-state-signing', + + // Post-login redirect configuration + postLoginPath: appConfig.postLoginPath, + allowContinueParam: appConfig.allowContinueParam }; }