feat: add POST_LOGIN_PATH and ALLOW_CONTINUE_PARAM env vars

- Added POST_LOGIN_PATH env var (default: '/')
- Added ALLOW_CONTINUE_PARAM env var (default: true)
- Updated appConfigMap to read and pass these values
- Updated oidcConfigBuilder to include them in OIDC config

This allows consumers to configure post-login redirect behavior via env vars.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
master
Guillermo Pages 3 months ago
parent e1c35de4f5
commit 2c254d490f

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

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

@ -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<string, string> };
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<BiblioStatsGraphQLContext>) {
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<T extends {}>(
context: GqlResolversContextParams<BiblioStatsGraphQLContext>,
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.

@ -0,0 +1,70 @@
# STEP 04 Provide Redis runtime for SwissOID sessions
Added a Redis service to biblio-stats-graphqls docker compose files so swissoid-backs 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.

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

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

Loading…
Cancel
Save