feat: seems migrated

master
Guillermo Pages 3 months ago
parent f66c3d08c3
commit 157b11d45d

1
.gitignore vendored

@ -3,6 +3,7 @@ db-backups/
.DS_Store
# build
build/
dist/
# nyc
.nyc_output
converage

@ -0,0 +1,187 @@
# swissoid-back
SwissOID authentication package for Node.js backends. Provides reusable authentication components for integrating SwissOID (OpenID Connect) authentication into Node.js applications.
## Features
- 🔐 Complete OIDC Authorization Code Flow implementation
- 🍪 Secure cookie-based session management
- 🔑 JWT verification with JWKS support
- 📦 Dependency Injection ready (di-why compatible)
- 🚀 TypeScript support with full type definitions
- ⚡ Redis-based session storage
## Installation
```bash
npm install swissoid-back
```
## Usage
### Basic Setup with Dependency Injection
```typescript
import {
oidcRoutesLDEGen,
swissoidSessionServiceLDEGen,
cookieManagerLDEGen,
swissoidJWTVerifierLDEGen,
redisClientLDEGen
} from 'swissoid-back';
import DiContainer from 'di-why';
const container = new DiContainer({
load: {
// Redis client for session storage
redisClient: redisClientLDEGen(),
// Session service
sessionService: swissoidSessionServiceLDEGen(),
// Cookie manager
cookieManager: cookieManagerLDEGen({
domain: '.example.com',
sessionName: 'app_session'
}),
// JWT verifier
jwtVerifier: swissoidJWTVerifierLDEGen(),
// OIDC routes
oidcRoutes: oidcRoutesLDEGen({
clientId: 'your-client-id',
cookieDomain: '.example.com'
})
}
});
```
### Express Integration
```typescript
import express from 'express';
import cookieParser from 'cookie-parser';
const app = express();
// Required middleware
app.use(cookieParser());
// Mount OIDC routes
const oidcRoutes = await container.get('oidcRoutes');
app.use(oidcRoutes);
// Routes provided:
// GET /login - Initiates OIDC flow
// POST /oidc/callback - Handles callback from IdP
// GET /oidc/finalize - Sets cookies in first-party context
// GET /auth/status - Returns authentication status
// GET /auth/userinfo - Returns user information
// GET /auth/logout - Destroys session
// POST /auth/logout - Destroys session (JSON response)
```
### Configuration
Required environment variables:
```bash
# SwissOID Configuration
SWISSOID_ISSUER=https://api.swissoid.com
SWISSOID_CLIENT_ID=your-client-id
SWISSOID_CLIENT_SECRET=your-client-secret
SWISSOID_TOKEN_ENDPOINT=https://api.swissoid.com/token
SWISSOID_JWKS_URI=https://api.swissoid.com/.well-known/jwks.json
SWISSOID_AUTHORIZE_ENDPOINT=https://api.swissoid.com/authorize
# RP Configuration
RP_CALLBACK_URL=https://your-app.com/oidc/callback
RP_COOKIE_DOMAIN=.your-app.com
RP_FRONTEND_URL=https://app.your-app.com
# Session Configuration
SESSION_COOKIE_NAME=app_session
SESSION_SECRET=your-session-secret
STATE_SIGNING_SECRET=your-state-signing-secret
# Redis
REDIS_URL=redis://localhost:6379
```
### Manual Usage (without DI)
```typescript
import { createOIDCRoutes, SwissOIDSessionService, CookieManager } from 'swissoid-back';
import Redis from 'ioredis';
// Create Redis client
const redisClient = new Redis('redis://localhost:6379');
// Create session service
const sessionService = new SwissOIDSessionService(redisClient, console);
// Create cookie manager
const cookieManager = new CookieManager({
domain: '.example.com',
sessionName: 'app_session',
secureCookie: true,
sameSite: 'lax'
});
// Create OIDC routes
const oidcRoutes = createOIDCRoutes({
logger: console,
sessionService,
redisClient,
issuer: 'https://api.swissoid.com',
clientId: 'your-client-id',
// ... other config
});
app.use(oidcRoutes);
```
## API Reference
### Components
- **oidcRoutesLDEGen**: Loader for OIDC route handlers
- **swissoidSessionServiceLDEGen**: Loader for session management service
- **cookieManagerLDEGen**: Loader for cookie operations
- **swissoidJWTVerifierLDEGen**: Loader for JWT verification
- **redisClientLDEGen**: Loader for Redis client
### Types
```typescript
interface OIDCConfig {
issuer: string;
clientId: string;
clientSecret?: string;
tokenEndpoint: string;
jwksUri: string;
authorizeEndpoint: string;
callbackUrl: string;
cookieDomain: string;
frontendUrl: string;
sessionCookieName: string;
sessionSecret: string;
stateSigningSecret: string;
}
interface SessionData {
sub: string;
email?: string;
name?: string;
iat: number;
exp: number;
createdAt?: number;
lastAccessedAt?: number;
metadata?: Record<string, any>;
}
```
## License
MIT

@ -0,0 +1,74 @@
# Redis Requirements for swissoid-back
## Overview
swissoid-back requires Redis for managing authentication sessions and security tokens. Redis is used for:
1. **Session Storage**: Storing user session data after successful authentication
2. **JTI Replay Prevention**: Ensuring single-use of JWT tokens to prevent replay attacks
3. **Transit Token Storage**: Temporary tokens during the login flow (60-second TTL)
## Redis Configuration
The Redis client in swissoid-back expects the following configuration from `appConfig`:
- `redisHost`: Redis server hostname (default: 'localhost')
- `redisPort`: Redis server port (default: 6379)
- `redisPassword`: Redis password (optional)
- `redisUrl`: Complete Redis URL (overrides host/port/password if provided)
- `redisDb`: Redis database number (default: 0)
## Docker Deployment
See `docker-compose.example.yml` for a complete example. Here's the minimal Redis service configuration:
```yaml
services:
swissoid-redis:
image: redis:latest
container_name: swissoid-redis
expose:
- 6379
volumes:
- redis:/data
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
```
## Environment Variables
When deploying an application using swissoid-back, provide these environment variables:
```bash
REDIS_HOST=swissoid-redis
REDIS_PORT=6379
REDIS_DB=0
# Or use a complete URL:
# REDIS_URL=redis://swissoid-redis:6379/0
```
## Redis Data Structure
swissoid-back uses the following Redis key patterns:
- `session:{sessionId}`: User session data (TTL: 7 days by default)
- `oidc_jti:{jti}`: JTI tokens for replay prevention (TTL: 10 minutes)
- `login_tx:{transitToken}`: Transit tokens during login flow (TTL: 60 seconds)
## Connection Handling
The Redis client includes:
- Automatic retry strategy with exponential backoff
- Connection health checks
- Error logging
- Ready state verification with ping test
## Security Considerations
1. **Network Isolation**: Keep Redis on an internal network, not exposed to the internet
2. **Password Protection**: Use `REDIS_PASSWORD` in production environments
3. **Data Persistence**: Configure Redis volumes for session persistence across restarts
4. **TTL Management**: Sessions expire after 7 days by default (configurable via `sessionTTL`)

@ -0,0 +1,62 @@
version: "3.9"
# Example Docker Compose configuration for swissoid-back Redis requirement
# This Redis service is required for session storage in swissoid-back
services:
# Redis service for session storage
# Required by swissoid-back for:
# - Session management (storing user sessions)
# - JTI replay prevention (single-use token enforcement)
# - Transit token storage (temporary tokens during login flow)
swissoid-redis:
image: redis:latest
container_name: ${REDIS_HOST:-swissoid-redis}
expose:
- 6379
volumes:
- redis:/data
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
networks:
- app_network
# Example application using swissoid-back
# your-app:
# image: your-app:latest
# environment:
# # Redis configuration for swissoid-back
# REDIS_HOST: ${REDIS_HOST:-swissoid-redis}
# REDIS_PORT: ${REDIS_PORT:-6379}
# REDIS_DB: ${REDIS_DB:-0}
#
# # SwissOID configuration
# SWISSOID_ISSUER: ${SWISSOID_ISSUER}
# SWISSOID_CLIENT_ID: ${SWISSOID_CLIENT_ID}
# SWISSOID_CLIENT_SECRET: ${SWISSOID_CLIENT_SECRET}
# SWISSOID_TOKEN_ENDPOINT: ${SWISSOID_TOKEN_ENDPOINT}
# SWISSOID_JWKS_URI: ${SWISSOID_JWKS_URI}
#
# # RP configuration
# COOKIE_DOMAIN: ${COOKIE_DOMAIN}
# RP_FRONTEND_URL: ${RP_FRONTEND_URL}
# SESSION_COOKIE_NAME: ${SESSION_COOKIE_NAME}
# SESSION_SECRET: ${SESSION_SECRET}
# depends_on:
# - swissoid-redis
# networks:
# - app_network
volumes:
redis:
name: "swissoid-redis-volume"
networks:
app_network:
name: swissoid-app_network
external: false

8669
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,45 @@
{
"name": "swissoid-back",
"version": "1.0.0",
"description": "SwissOID authentication package for Node.js backends",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "jest",
"prepublishOnly": "npm run build"
},
"keywords": [
"swissoid",
"oidc",
"authentication",
"oauth2",
"session"
],
"author": "",
"license": "MIT",
"dependencies": {
"@types/express-serve-static-core": "^5.0.7",
"@types/node-fetch": "^2.6.13",
"cookie-parser": "^1.4.7",
"di-why": "^0.20.0",
"express": "^5.1.0",
"ioredis": "^5.7.0",
"jose": "^6.1.0",
"node-fetch": "^3.3.2",
"redis": "^5.8.2"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.0",
"@types/jest": "^29.0.0",
"@types/node": "^18.0.0",
"jest": "^30.1.3",
"ts-jest": "^29.4.4",
"typescript": "^5.9.2"
},
"files": [
"dist"
]
}

@ -0,0 +1,108 @@
const appConfigMap = function (env: {
// SwissOID configuration
SWISSOID_ISSUER?: string;
SWISSOID_CLIENT_ID?: string;
SWISSOID_CLIENT_SECRET?: string;
SWISSOID_TOKEN_ENDPOINT?: string;
SWISSOID_JWKS_URI?: string;
SWISSOID_AUTHORIZE_ENDPOINT?: string;
// Redis configuration
REDIS_URL?: string;
REDIS_HOST?: string;
REDIS_PORT?: string;
REDIS_PASSWORD?: string;
REDIS_DB?: string;
// RP configuration
RP_CALLBACK_URL?: string;
OIDC_REDIRECT_BASE_URL?: string; // Alternative to RP_CALLBACK_URL
RP_COOKIE_DOMAIN?: string;
RP_FRONTEND_URL?: string;
COOKIE_DOMAIN?: string;
// Session configuration
SESSION_COOKIE_NAME?: string;
SESSION_SECRET?: string;
STATE_SIGNING_SECRET?: string;
// Cookie configuration
REFRESH_COOKIE_NAME?: string;
SESSION_TTL?: string;
REFRESH_TTL?: string;
}) {
// SwissOID configuration validation
if (undefined === env.SWISSOID_ISSUER) {
throw new Error('Missing .env var SWISSOID_ISSUER. The SwissOID issuer URL, e.g., https://api.swissoid.com');
}
if (undefined === env.SWISSOID_CLIENT_ID) {
throw new Error('Missing .env var SWISSOID_CLIENT_ID. The client ID registered with SwissOID');
}
if (undefined === env.SWISSOID_TOKEN_ENDPOINT) {
throw new Error('Missing .env var SWISSOID_TOKEN_ENDPOINT. Required for token exchange, e.g., https://api.swissoid.com/token');
}
if (undefined === env.SWISSOID_JWKS_URI) {
throw new Error('Missing .env var SWISSOID_JWKS_URI. Required for JWT verification, e.g., https://api.swissoid.com/.well-known/jwks.json');
}
// Redis configuration - REDIS_URL is optional if host/port are provided
if (!env.REDIS_URL && !env.REDIS_HOST) {
throw new Error('Missing Redis configuration. Provide either REDIS_URL or REDIS_HOST');
}
// RP configuration validation
if (!env.RP_CALLBACK_URL && !env.OIDC_REDIRECT_BASE_URL) {
throw new Error('Missing callback URL configuration. Provide either RP_CALLBACK_URL or OIDC_REDIRECT_BASE_URL');
}
if (!env.RP_FRONTEND_URL) {
throw new Error('Missing .env var RP_FRONTEND_URL. Required for post-auth redirect, e.g., https://app.clockize.com');
}
if (!env.COOKIE_DOMAIN && !env.RP_COOKIE_DOMAIN) {
throw new Error('Missing cookie domain configuration. Provide either COOKIE_DOMAIN or RP_COOKIE_DOMAIN');
}
// Session configuration validation
if (undefined === env.SESSION_COOKIE_NAME) {
throw new Error('Missing .env var SESSION_COOKIE_NAME. Required for session management');
}
if (undefined === env.SESSION_SECRET) {
throw new Error('Missing .env var SESSION_SECRET. Required for session security');
}
return {
// SwissOID configuration
swissoidIssuer: env.SWISSOID_ISSUER,
swissoidClientId: env.SWISSOID_CLIENT_ID,
swissoidClientSecret: env.SWISSOID_CLIENT_SECRET,
swissoidTokenEndpoint: env.SWISSOID_TOKEN_ENDPOINT,
swissoidJwksUri: env.SWISSOID_JWKS_URI,
swissoidAuthorizeEndpoint: env.SWISSOID_AUTHORIZE_ENDPOINT || `${env.SWISSOID_ISSUER}/authorize`,
// Redis configuration
redisUrl: env.REDIS_URL,
redisHost: env.REDIS_HOST || 'localhost',
redisPort: env.REDIS_PORT ? parseInt(env.REDIS_PORT) : 6379,
redisPassword: env.REDIS_PASSWORD,
redisDb: env.REDIS_DB ? parseInt(env.REDIS_DB) : 0,
// RP configuration
rpCallbackUrl: env.RP_CALLBACK_URL || (env.OIDC_REDIRECT_BASE_URL ? `${env.OIDC_REDIRECT_BASE_URL}/oidc/callback` : undefined),
rpCookieDomain: env.RP_COOKIE_DOMAIN || env.COOKIE_DOMAIN,
rpFrontendUrl: env.RP_FRONTEND_URL,
cookieDomain: env.COOKIE_DOMAIN || env.RP_COOKIE_DOMAIN,
// Session configuration
sessionCookieName: env.SESSION_COOKIE_NAME,
sessionSecret: env.SESSION_SECRET,
stateSigningSecret: env.STATE_SIGNING_SECRET || (env.SESSION_SECRET + '-state-signing'),
// Cookie configuration
refreshCookieName: env.REFRESH_COOKIE_NAME || 'rid',
sessionTTL: env.SESSION_TTL ? parseInt(env.SESSION_TTL) : 7200,
refreshTTL: env.REFRESH_TTL ? parseInt(env.REFRESH_TTL) : 604800,
};
}
export default appConfigMap;
export type SwissoidAppConfigMap = typeof appConfigMap;

@ -0,0 +1,106 @@
import { Request, Response } from 'express';
import { CookieConfig } from '../types/auth.types';
/**
* Manages cookie operations for authentication
*/
export class CookieManager {
private readonly config: CookieConfig;
constructor(config: CookieConfig) {
this.config = {
domain: config.domain,
sessionName: config.sessionName || 'sid',
refreshName: config.refreshName || 'rid',
secureCookie: config.secureCookie !== false,
sameSite: config.sameSite || 'lax',
httpOnly: config.httpOnly !== false,
sessionTTL: config.sessionTTL || 7200, // 2 hours
refreshTTL: config.refreshTTL || 604800 // 7 days
};
}
/**
* Get standard cookie options
*/
private getCookieOptions(maxAge?: number) {
return {
domain: this.config.domain,
httpOnly: this.config.httpOnly,
secure: this.config.secureCookie,
sameSite: this.config.sameSite as 'strict' | 'lax' | 'none',
path: '/',
...(maxAge && { maxAge })
};
}
/**
* Set authentication cookies (session and optionally refresh)
*/
setAuthCookies(res: Response, { sessionId, refreshId }: { sessionId: string; refreshId?: string }) {
// Set session cookie
res.cookie(
this.config.sessionName,
sessionId,
this.getCookieOptions((this.config.sessionTTL || 7200) * 1000)
);
// Set refresh cookie if provided
if (refreshId && this.config.refreshName) {
res.cookie(
this.config.refreshName,
refreshId,
this.getCookieOptions((this.config.refreshTTL || 604800) * 1000)
);
}
}
/**
* Clear authentication cookies
*/
clearAuthCookies(res: Response) {
res.clearCookie(this.config.sessionName, this.getCookieOptions());
if (this.config.refreshName) {
res.clearCookie(this.config.refreshName, this.getCookieOptions());
}
}
/**
* Extract cookies from request
*/
extractCookies(req: Request): { sessionId?: string; refreshId?: string } {
return {
sessionId: req.cookies?.[this.config.sessionName],
refreshId: this.config.refreshName ? req.cookies?.[this.config.refreshName] : undefined
};
}
/**
* Check if request has valid session cookie
*/
hasSessionCookie(req: Request): boolean {
return !!req.cookies?.[this.config.sessionName];
}
/**
* Check if request has valid refresh cookie
*/
hasRefreshCookie(req: Request): boolean {
return !!(this.config.refreshName && req.cookies?.[this.config.refreshName]);
}
/**
* Get session ID from request
*/
getSessionId(req: Request): string | undefined {
return req.cookies?.[this.config.sessionName];
}
/**
* Get refresh ID from request
*/
getRefreshId(req: Request): string | undefined {
return this.config.refreshName ? req.cookies?.[this.config.refreshName] : undefined;
}
}

@ -0,0 +1,21 @@
import { LoadDictElement } from 'di-why/build/src/DiContainer';
import { CookieManager } from './CookieManager';
import { CookieConfig } from '../types/auth.types';
export const cookieManagerLDEGen = (config?: Partial<CookieConfig>): LoadDictElement<CookieManager> => ({
factory: ({ appConfig }) => {
return new CookieManager({
domain: config?.domain || appConfig.rpCookieDomain || appConfig.cookieDomain,
sessionName: config?.sessionName || appConfig.sessionCookieName || 'sid',
refreshName: config?.refreshName || appConfig.refreshCookieName || 'rid',
secureCookie: config?.secureCookie !== undefined ? config.secureCookie : true,
sameSite: config?.sameSite || 'lax',
httpOnly: config?.httpOnly !== undefined ? config.httpOnly : true,
sessionTTL: config?.sessionTTL || appConfig.sessionTTL || 7200,
refreshTTL: config?.refreshTTL || appConfig.refreshTTL || 604800
});
},
locateDeps: {
appConfig: 'appConfig'
}
});

@ -0,0 +1,41 @@
/**
* swissoid-back - SwissOID authentication package for Node.js backends
*
* This package provides reusable authentication components for integrating
* SwissOID authentication into Node.js applications.
*/
// Main loadDict for use with di-why (following graphql-knifey pattern)
import swissoidAuthLoadDict from './loaders';
export { swissoidAuthLoadDict };
export default swissoidAuthLoadDict;
// Individual loaders if needed separately
export { sessionService, cookieManager, oidcStandardRoutes } from './loaders';
// Export utilities
export { swissoidAppConfigMapNamespace } from './utils/swissoidAppConfigMapListAdd';
export { default as appConfigMap } from './config/appConfigMap';
export { swissoidMergeAppConfigMap } from './utils/swissoidMergeAppConfigMap';
// Export the key for the appConfigMap namespace
export const swissoidAppConfigMapKey = 'swissoidAppConfigMapNamespace';
// Classes and functions for direct use
export { createOIDCRoutes } from './oidc/OIDCRoutes';
export { SwissOIDSessionService } from './session/SwissOIDSessionService';
export { CookieManager } from './cookies/CookieManager';
export { SwissOIDJWTVerifier } from './jwt/SwissOIDJWTVerifier';
// Types
export type {
OIDCConfig,
CookieConfig,
SessionData,
DATClaims,
SwissOIDSessionConfig
} from './types/auth.types';
// Re-export useful types from dependencies
export type { Request, Response, Router } from 'express';
export type { LoadDictElement } from 'di-why/build/src/DiContainer';

@ -0,0 +1,132 @@
import { createRemoteJWKSet, jwtVerify, JWTVerifyResult, JWTPayload } from 'jose';
/**
* JWT Verifier for SwissOID tokens
*/
export class SwissOIDJWTVerifier {
private jwks: ReturnType<typeof createRemoteJWKSet>;
constructor(
private config: {
jwksUri: string;
issuer: string;
audience?: string;
}
) {
this.jwks = createRemoteJWKSet(new URL(config.jwksUri));
}
/**
* Verify a JWT token
*/
async verify(token: string, options?: {
audience?: string;
maxTokenAge?: string;
clockTolerance?: number;
}): Promise<{ valid: boolean; payload?: JWTPayload; error?: Error }> {
try {
const result: JWTVerifyResult = await jwtVerify(token, this.jwks, {
issuer: this.config.issuer,
audience: options?.audience || this.config.audience,
algorithms: ['RS256'],
maxTokenAge: options?.maxTokenAge,
clockTolerance: options?.clockTolerance || 5
});
return {
valid: true,
payload: result.payload
};
} catch (error) {
return {
valid: false,
error: error as Error
};
}
}
/**
* Verify and extract claims from a token
*/
async verifyClaims<T extends Record<string, any>>(
token: string,
options?: {
audience?: string;
requiredClaims?: string[];
}
): Promise<{ valid: boolean; claims?: T; error?: Error }> {
const result = await this.verify(token, options);
if (!result.valid) {
return {
valid: false,
error: result.error
};
}
// Check required claims if specified
if (options?.requiredClaims) {
for (const claim of options.requiredClaims) {
if (!(claim in result.payload!)) {
return {
valid: false,
error: new Error(`Missing required claim: ${claim}`)
};
}
}
}
return {
valid: true,
claims: result.payload as T
};
}
/**
* Verify an ID token from SwissOID
*/
async verifyIdToken(idToken: string, nonce?: string): Promise<{
valid: boolean;
payload?: JWTPayload;
error?: Error;
}> {
const result = await this.verify(idToken);
if (!result.valid) {
return result;
}
// Verify nonce if provided
if (nonce && result.payload?.nonce !== nonce) {
return {
valid: false,
error: new Error('Nonce mismatch')
};
}
return result;
}
/**
* Extract user information from ID token
*/
extractUserInfo(payload: JWTPayload): {
sub: string;
email?: string;
name?: string;
given_name?: string;
family_name?: string;
picture?: string;
email_verified?: boolean;
} {
return {
sub: payload.sub!,
email: payload.email as string | undefined,
name: payload.name as string | undefined,
given_name: payload.given_name as string | undefined,
family_name: payload.family_name as string | undefined,
picture: payload.picture as string | undefined,
email_verified: payload.email_verified as boolean | undefined
};
}
}

@ -0,0 +1,19 @@
import { LoadDictElement } from 'di-why/build/src/DiContainer';
import { SwissOIDJWTVerifier } from './SwissOIDJWTVerifier';
export const swissoidJWTVerifierLDEGen = (config?: {
jwksUri?: string;
issuer?: string;
audience?: string;
}): LoadDictElement<SwissOIDJWTVerifier> => ({
factory: ({ appConfig }) => {
return new SwissOIDJWTVerifier({
jwksUri: config?.jwksUri || appConfig.swissoidJwksUri,
issuer: config?.issuer || appConfig.swissoidIssuer,
audience: config?.audience || appConfig.swissoidClientId
});
},
locateDeps: {
appConfig: 'appConfig'
}
});

@ -0,0 +1,22 @@
import { LoadDictElement } from 'di-why/build/src/DiContainer';
import { CookieManager } from '../cookies/CookieManager';
const loadDictElement: LoadDictElement<CookieManager> = {
factory: ({ appConfig }) => {
return new CookieManager({
domain: appConfig.rpCookieDomain || appConfig.cookieDomain,
sessionName: appConfig.sessionCookieName || 'sid',
refreshName: appConfig.refreshCookieName || 'rid',
secureCookie: true,
sameSite: 'lax',
httpOnly: true,
sessionTTL: appConfig.sessionTTL || 7200,
refreshTTL: appConfig.refreshTTL || 604800
});
},
locateDeps: {
appConfig: 'appConfig'
}
};
export default loadDictElement;

@ -0,0 +1,21 @@
import { LoadDict } from 'di-why';
import redisClient from './redisClient';
import sessionService from './sessionService';
import cookieManager from './cookieManager';
import oidcStandardRoutes from './oidcStandardRoutes';
import appConfigMap from '../config/appConfigMap';
import { swissoidAppConfigMapNamespace } from '../utils/swissoidAppConfigMapListAdd';
import { addMergeableConfigMap } from 'di-why';
export { redisClient, sessionService, cookieManager, oidcStandardRoutes };
const loadDict: LoadDict = {
redisClient,
sessionService,
cookieManager,
oidcStandardRoutes,
...addMergeableConfigMap(appConfigMap, swissoidAppConfigMapNamespace),
};
export default loadDict;

@ -0,0 +1,69 @@
import { LoadDictElement } from 'di-why/build/src/DiContainer';
import { Express } from 'express';
import { createOidcStandardRoutes } from '../oidc/OIDCStandardRoutes';
const loadDictElement: LoadDictElement<string> = {
factory: ({
app,
logger,
sessionService,
appConfig,
redisClient
}) => {
// Debug log appConfig to see what's available
logger.log('[OIDC_LOADER] AppConfig received:', {
hasCookieDomain: 'cookieDomain' in appConfig,
cookieDomainValue: appConfig.cookieDomain || 'UNDEFINED',
cookieDomainType: typeof appConfig.cookieDomain,
appConfigKeys: Object.keys(appConfig).sort(),
// Log a few other expected keys to verify appConfig structure
hasSwissoidIssuer: 'swissoidIssuer' in appConfig,
swissoidIssuerValue: appConfig.swissoidIssuer || 'UNDEFINED'
});
// Configuration for standard OIDC flow with signed state
const config = {
logger,
sessionService,
redisClient, // Added for JTI replay prevention
// SwissOID configuration
swissoidIssuer: appConfig.swissoidIssuer,
swissoidClientId: appConfig.swissoidClientId,
swissoidClientSecret: appConfig.swissoidClientSecret,
swissoidTokenEndpoint: appConfig.swissoidTokenEndpoint,
swissoidJwksUri: appConfig.swissoidJwksUri,
swissoidAuthorizeEndpoint: appConfig.swissoidAuthorizeEndpoint || `${appConfig.swissoidIssuer}/authorize`,
// RP configuration
rpCallbackUrl: appConfig.rpCallbackUrl,
rpCookieDomain: appConfig.cookieDomain,
rpFrontendUrl: appConfig.rpFrontendUrl,
// Session configuration
sessionCookieName: appConfig.sessionCookieName,
sessionSecret: appConfig.sessionSecret,
// State signing secret (use a dedicated secret or derive from session secret)
stateSigningSecret: appConfig.stateSigningSecret || appConfig.sessionSecret + '-state-signing'
};
const router = createOidcStandardRoutes(config);
// Mount the standard OIDC routes
(app as Express).use(router);
logger.log('Standard OIDC routes mounted - /login, POST /oidc/callback, /auth/status, /auth/userinfo, /auth/logout');
return 'oidcStandardRoutes-loaded';
},
locateDeps: {
app: 'app',
logger: 'logger',
sessionService: 'sessionService',
appConfig: 'appConfig',
redisClient: 'redisClient'
}
};
export default loadDictElement;

@ -0,0 +1,56 @@
import { LoadDictElement } from 'di-why/build/src/DiContainer';
import { Redis } from 'ioredis';
const loadDictElement: LoadDictElement<Redis> = {
factory: ({ logger, appConfig }) => {
// Build Redis URL from appConfig or use defaults
const redisHost = appConfig.redisHost || 'localhost';
const redisPort = appConfig.redisPort || 6379;
const redisPassword = appConfig.redisPassword || '';
// Use appConfig.redisUrl if available, otherwise build from components
const redisUrl = appConfig.redisUrl ||
(redisPassword ? `redis://:${redisPassword}@${redisHost}:${redisPort}` : `redis://${redisHost}:${redisPort}`);
logger.log(`Connecting to Redis at ${redisHost}:${redisPort}`);
const client = new Redis(redisUrl, {
db: appConfig.redisDb || 0,
retryStrategy: (times: number) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
enableOfflineQueue: true
});
client.on('connect', () => {
logger.log('Redis client connected');
});
client.on('error', (err: Error) => {
logger.log('ERROR: Redis client error:', err);
});
client.on('ready', () => {
logger.log('Redis client ready');
});
return client;
},
locateDeps: {
logger: 'logger',
appConfig: 'appConfig'
},
after({ me: client, deps: { logger } }) {
client.ping().then(() => {
logger.log('Redis connection test successful');
}).catch((error: Error) => {
logger.log('ERROR: Redis connection test failed:', error);
});
}
};
export default loadDictElement;

@ -0,0 +1,13 @@
import { LoadDictElement } from 'di-why/build/src/DiContainer';
import { SwissOIDSessionService } from '../session/SwissOIDSessionService';
const loadDictElement: LoadDictElement<SwissOIDSessionService> = {
constructible: SwissOIDSessionService,
destructureDeps: true,
locateDeps: {
redisClient: 'redisClient',
logger: 'logger'
}
};
export default loadDictElement;

@ -0,0 +1,391 @@
import { Router, Request, Response } from 'express';
import express from 'express';
import cookieParser from 'cookie-parser';
import * as crypto from 'crypto';
import { jwtVerify, createRemoteJWKSet, SignJWT } from 'jose';
import { OIDCConfig, SessionData } from '../types/auth.types';
export interface OIDCRoutesConfig extends OIDCConfig {
logger: any;
sessionService: any;
redisClient: any;
}
export function createOIDCRoutes(config: OIDCRoutesConfig): Router {
const router = Router();
// Add cookie parser middleware
router.use(cookieParser());
const {
logger,
sessionService,
redisClient,
issuer,
clientId,
clientSecret,
tokenEndpoint,
jwksUri,
authorizeEndpoint,
callbackUrl,
cookieDomain,
frontendUrl,
sessionCookieName,
stateSigningSecret
} = config;
// Create the secret key for signing
const secretKey = new TextEncoder().encode(stateSigningSecret);
// Cookie options
const getCookieOptions = () => ({
domain: cookieDomain,
httpOnly: true,
secure: true,
sameSite: 'lax' as const,
path: '/'
});
/**
* GET /auth/ping - Health check endpoint
*/
router.get('/auth/ping', (req: Request, res: Response) => {
res.json({
status: 'ok',
service: 'swissoid-back',
timestamp: new Date().toISOString()
});
});
/**
* GET /login - Initiates OIDC flow
*/
router.get('/login', async (req: Request, res: Response) => {
try {
const jti = crypto.randomBytes(16).toString('base64url');
const nonce = crypto.randomBytes(16).toString('base64url');
// Create signed state token
const state = await new SignJWT({
jti,
nonce,
client_id: clientId,
redirect_uri: callbackUrl
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('10m')
.sign(secretKey);
// Store JTI for replay prevention
await redisClient.set(
`oidc_jti:${jti}`,
'1',
'NX',
'EX',
600
);
// Set nonce cookie for backward compatibility
res.cookie('rp_nonce', nonce, {
...getCookieOptions(),
maxAge: 10 * 60 * 1000
});
logger.log('Initiating OIDC flow', {
client_id: clientId,
jti_hash: crypto.createHash('sha256').update(jti).digest('hex').substring(0, 8)
});
// Build authorization URL
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: callbackUrl,
response_type: 'code',
response_mode: 'form_post',
scope: 'openid',
state: state,
nonce: nonce
});
const authorizationUrl = `${authorizeEndpoint}?${params.toString()}`;
return res.redirect(302, authorizationUrl);
} catch (error) {
logger.error('Error initiating login', error);
return res.status(500).send('Internal server error');
}
});
/**
* POST /oidc/callback - Handles callback from SwissOID
*/
router.post('/oidc/callback',
express.urlencoded({ extended: false }),
async (req: Request, res: Response) => {
try {
const { code, state } = req.body;
if (!code || !state) {
logger.error('Missing code or state in callback');
return res.status(400).send('Missing required parameters');
}
// Verify signed state token
let statePayload: any;
try {
const { payload } = await jwtVerify(state, secretKey, {
algorithms: ['HS256'],
clockTolerance: 5
});
statePayload = payload;
} catch (verifyError) {
logger.error('State token verification failed', verifyError);
return res.status(400).send('Invalid state parameter');
}
// Enforce single-use via JTI
const jti = statePayload.jti;
const jtiKey = `oidc_jti:${jti}`;
const deleted = await redisClient.del(jtiKey);
if (deleted === 0) {
logger.error('JTI already used or expired', { jti });
return res.status(400).send('State already used or expired');
}
// Validate embedded parameters
if (statePayload.client_id !== clientId || statePayload.redirect_uri !== callbackUrl) {
logger.error('State parameter mismatch');
return res.status(400).send('Invalid state parameter');
}
// Exchange code for tokens
const tokenParams = new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: callbackUrl
});
const authHeader = clientSecret
? 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
: undefined;
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(authHeader && { 'Authorization': authHeader })
},
body: tokenParams.toString()
});
if (!tokenResponse.ok) {
logger.error('Token exchange failed', { status: tokenResponse.status });
return res.status(401).send('Token exchange failed');
}
const tokenData = await tokenResponse.json() as any;
const { id_token } = tokenData;
if (!id_token) {
logger.error('No id_token in response');
return res.status(401).send('No id_token received');
}
// Verify id_token
const JWKS = createRemoteJWKSet(new URL(jwksUri));
let payload: any;
try {
const result = await jwtVerify(id_token, JWKS, {
issuer: issuer,
audience: clientId,
algorithms: ['RS256'],
clockTolerance: 5
});
payload = result.payload;
} catch (verifyError) {
logger.error('ID token verification failed', verifyError);
return res.status(401).send('Invalid id_token');
}
// Verify nonce
if (!payload.nonce || payload.nonce !== statePayload.nonce) {
logger.error('Nonce mismatch');
return res.status(401).send('Nonce mismatch');
}
// Clear nonce cookie
res.clearCookie('rp_nonce', getCookieOptions());
// Create session
const sessionId = crypto.randomBytes(24).toString('base64url');
await sessionService.createSession(sessionId, {
sub: payload.sub,
iat: payload.iat,
exp: payload.exp,
email: payload.email,
name: payload.name
});
// Create transit token for finalize endpoint
const transitToken = crypto.randomBytes(32).toString('base64url');
await redisClient.set(
`login_tx:${transitToken}`,
sessionId,
'EX',
60
);
// Redirect to finalize endpoint
const finalizeUrl = `${callbackUrl.replace('/oidc/callback', '/oidc/finalize')}?tx=${transitToken}`;
return res.redirect(303, finalizeUrl);
} catch (error) {
logger.error('Error processing OIDC callback', error);
return res.status(500).send('Internal server error');
}
});
/**
* GET /oidc/finalize - Sets session cookie in first-party context
*/
router.get('/oidc/finalize', async (req: Request, res: Response) => {
try {
const { tx } = req.query;
if (!tx || typeof tx !== 'string') {
logger.error('Missing transit token');
return res.status(400).send('Missing transit token');
}
// Retrieve and delete session ID from Redis
const transitKey = `login_tx:${tx}`;
const sessionId = await redisClient.get(transitKey);
if (!sessionId) {
logger.error('Transit token not found or expired');
return res.status(400).send('Transit token expired');
}
await redisClient.del(transitKey);
// Verify session exists
const sessionData = await sessionService.getSession(sessionId);
if (!sessionData) {
logger.error('Session not found');
return res.status(400).send('Session not found');
}
// Set session cookie
res.cookie(sessionCookieName, sessionId, {
...getCookieOptions(),
maxAge: 7 * 24 * 60 * 60 * 1000
});
// Redirect to app
const redirectUrl = `${frontendUrl}/workspace`;
return res.redirect(302, redirectUrl);
} catch (error) {
logger.error('Error in finalize endpoint', error);
return res.status(500).send('Internal server error');
}
});
/**
* GET /auth/status - Returns authentication status
*/
router.get('/auth/status', async (req: Request, res: Response) => {
try {
const sessionId = req.cookies?.[sessionCookieName];
if (!sessionId) {
return res.json({ authenticated: false });
}
const session = await sessionService.getSession(sessionId);
if (!session) {
return res.json({ authenticated: false });
}
return res.json({
authenticated: true,
user: {
id: session.sub,
UUID: session.sub,
email: session.email,
username: session.email,
name: session.name,
...session
}
});
} catch (error) {
logger.error('Error checking auth status', error);
return res.json({ authenticated: false });
}
});
/**
* GET/POST /auth/logout - Destroys session and clears cookies
*/
const logoutHandler = async (req: Request, res: Response) => {
try {
const sessionId = req.cookies?.[sessionCookieName];
if (sessionId) {
await sessionService.destroySession(sessionId);
}
res.clearCookie(sessionCookieName, getCookieOptions());
if (req.method === 'GET') {
return res.redirect('/login');
} else {
return res.json({ success: true });
}
} catch (error) {
logger.error('Error during logout', error);
if (req.method === 'GET') {
return res.redirect('/login');
} else {
return res.status(500).json({ error: 'Logout failed' });
}
}
};
router.get('/auth/logout', logoutHandler);
router.post('/auth/logout', logoutHandler);
/**
* GET /auth/userinfo - Returns user information (if authenticated)
*/
router.get('/auth/userinfo', async (req: Request, res: Response) => {
try {
const sessionId = req.cookies?.[sessionCookieName];
if (!sessionId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const session = await sessionService.getSession(sessionId);
if (!session) {
return res.status(401).json({ error: 'Invalid session' });
}
return res.json({
sub: session.sub,
email: session.email,
name: session.name,
...session
});
} catch (error) {
logger.error('Error fetching user info', error);
return res.status(500).json({ error: 'Internal server error' });
}
});
return router;
}

@ -0,0 +1,634 @@
import { Router, Request, Response } from 'express';
import express from 'express';
import cookieParser from 'cookie-parser';
import * as crypto from 'crypto';
import fetch from 'node-fetch';
import { jwtVerify, createRemoteJWKSet, SignJWT } from 'jose';
/**
* Standard OIDC Authorization Code Flow Routes for RP
* Implements the flow as specified in CLAUDE.md
* Using stateless signed state to avoid third-party cookie issues
*/
interface OidcStandardConfig {
logger: any;
sessionService: any;
redisClient: any; // Added for JTI replay prevention
// SwissOID configuration
swissoidIssuer: string; // https://api.swissoid.com
swissoidClientId: string; // clockize or cronide
swissoidClientSecret?: string; // For confidential clients
swissoidTokenEndpoint: string; // https://api.swissoid.com/token
swissoidJwksUri: string; // https://api.swissoid.com/.well-known/jwks.json
swissoidAuthorizeEndpoint: string; // https://api.swissoid.com/authorize
// RP configuration
rpCallbackUrl: string; // https://gateway.clockize.com/oidc/callback
rpCookieDomain: string; // .clockize.com
rpFrontendUrl: string; // https://app.clockize.com
// Session configuration
sessionCookieName: string; // clockize_session
sessionSecret: string;
// State signing secret (should be different from session secret)
stateSigningSecret: string;
}
export function createOidcStandardRoutes(config: OidcStandardConfig): Router {
const router = Router();
// Add cookie parser middleware to all routes
router.use(cookieParser());
// Debug endpoint to test connectivity
router.get('/auth/ping', (req: Request, res: Response) => {
res.json({
status: 'ok',
service: 'cronide-user',
timestamp: new Date().toISOString(),
headers: {
host: req.headers.host,
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-forwarded-proto': req.headers['x-forwarded-proto'],
'x-forwarded-host': req.headers['x-forwarded-host']
}
});
});
const {
logger,
sessionService,
redisClient,
swissoidIssuer,
swissoidClientId,
swissoidClientSecret,
swissoidTokenEndpoint,
swissoidJwksUri,
swissoidAuthorizeEndpoint,
rpCallbackUrl,
rpCookieDomain,
rpFrontendUrl,
sessionCookieName,
stateSigningSecret
} = config;
// Debug log the configuration
logger.log('OIDC Standard Routes initialized', {
rpCookieDomain: rpCookieDomain || 'UNDEFINED',
useSignedState: true,
swissoidIssuer
});
// Create the secret key for signing
const secretKey = new TextEncoder().encode(
stateSigningSecret || crypto.randomBytes(32).toString('base64')
);
// Cookie options for .clockize.com domain
// Using SameSite=Lax since we no longer need cross-site cookies
const getCookieOptions = () => ({
domain: rpCookieDomain,
httpOnly: true,
secure: true,
sameSite: 'lax' as const, // Changed from 'none' - no longer needed for cross-site
path: '/'
});
/**
* GET /login
* Initiates the standard OIDC Authorization Code Flow with signed state
* As per CLAUDE.md: RP Auth Backend (User Service) /login
*/
router.get('/login', async (req: Request, res: Response) => {
try {
// Generate crypto-random values
const jti = crypto.randomBytes(16).toString('base64url');
const nonce = crypto.randomBytes(16).toString('base64url');
// Create signed state token with all necessary data
const state = await new SignJWT({
jti, // Unique ID for replay prevention
nonce, // For id_token validation
client_id: swissoidClientId,
redirect_uri: rpCallbackUrl
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('10m') // 10 minute expiry
.sign(secretKey);
// Store JTI for replay prevention (single-use enforcement)
await redisClient.set(
`oidc_jti:${jti}`,
'1',
'NX', // Only set if not exists
'EX',
600 // 10 minutes TTL
);
// Still set nonce cookie for backward compatibility/defense-in-depth
// But not required for state validation
res.cookie('rp_nonce', nonce, {
...getCookieOptions(),
maxAge: 10 * 60 * 1000 // 10 minutes
});
logger.log('Initiating standard OIDC code flow with signed state', {
client_id: swissoidClientId,
jti_hash: crypto.createHash('sha256').update(jti).digest('hex').substring(0, 8),
state_length: state.length
});
// Build authorization URL for standard code flow
const params = new URLSearchParams({
client_id: swissoidClientId,
redirect_uri: rpCallbackUrl,
response_type: 'code', // Standard authorization code flow
response_mode: 'form_post', // SwissOID will form_post the code
scope: 'openid', // Minimal scope
state: state, // Signed state token
nonce: nonce // Still send nonce for id_token
});
const authorizationUrl = `${swissoidAuthorizeEndpoint || swissoidIssuer + '/authorize'}?${params.toString()}`;
// 302 redirect to IdP Backend (SwissOID API) /authorize
return res.redirect(302, authorizationUrl);
} catch (error) {
logger.error('Error initiating login', error);
return res.status(500).send('Internal server error');
}
});
/**
* POST /oidc/callback
* Receives the form_post from SwissOID with authorization code
* Now validates signed state from parameter instead of cookie
* As per CLAUDE.md: RP Auth Backend (User Service) /oidc/callback
*/
router.post('/oidc/callback',
express.urlencoded({ extended: false }), // Parse form_post data
async (req: Request, res: Response) => {
try {
const { code, state } = req.body;
logger.log('Received OIDC callback', {
hasCode: !!code,
hasState: !!state,
contentType: req.headers['content-type'],
origin: req.headers.origin || 'not-sent',
referer: req.headers.referer || 'not-sent',
codeLength: code?.length || 0,
stateLength: state?.length || 0,
method: req.method,
url: req.url,
host: req.headers.host
});
if (!code || !state) {
logger.error('Missing code or state in callback');
return res.status(400).send('Missing required parameters');
}
// Step 1: Verify signed state token
let statePayload: any;
try {
const { payload } = await jwtVerify(state, secretKey, {
algorithms: ['HS256'],
clockTolerance: 5 // 5 second clock skew tolerance
});
statePayload = payload;
} catch (verifyError) {
logger.error('State token verification failed', verifyError);
return res.status(400).send('Invalid state parameter');
}
// Step 2: Enforce single-use via JTI
const jti = statePayload.jti;
const jtiKey = `oidc_jti:${jti}`;
// Check if JTI was already used (delete returns 0 if key doesn't exist)
const deleted = await redisClient.del(jtiKey);
if (deleted === 0) {
logger.error('JTI already used or expired - possible replay attack', { jti });
return res.status(400).send('State already used or expired');
}
// Step 3: Validate embedded parameters
if (statePayload.client_id !== swissoidClientId) {
logger.error('Client ID mismatch in state', {
expected: swissoidClientId,
received: statePayload.client_id
});
return res.status(400).send('Invalid state parameter');
}
if (statePayload.redirect_uri !== rpCallbackUrl) {
logger.error('Redirect URI mismatch in state', {
expected: rpCallbackUrl,
received: statePayload.redirect_uri
});
return res.status(400).send('Invalid state parameter');
}
// Step 4: Exchange code at IdP /token endpoint (server-to-server)
const tokenParams = new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: rpCallbackUrl
});
// Add client authentication
const authHeader = swissoidClientSecret
? 'Basic ' + Buffer.from(`${swissoidClientId}:${swissoidClientSecret}`).toString('base64')
: undefined;
const tokenResponse = await fetch(swissoidTokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(authHeader && { 'Authorization': authHeader })
},
body: tokenParams.toString()
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.error('Token exchange failed', {
status: tokenResponse.status,
error: errorText
});
return res.status(401).send('Token exchange failed');
}
const tokenData = await tokenResponse.json() as any;
const { id_token } = tokenData;
if (!id_token) {
logger.error('No id_token in token response');
return res.status(401).send('No id_token received');
}
// Step 5: Verify id_token via JWKS
const JWKS = createRemoteJWKSet(new URL(swissoidJwksUri));
let payload: any;
try {
const result = await jwtVerify(id_token, JWKS, {
issuer: swissoidIssuer,
audience: swissoidClientId,
algorithms: ['RS256'],
clockTolerance: 5 // 5 second clock skew tolerance
});
payload = result.payload;
} catch (verifyError) {
logger.error('ID token verification failed', verifyError);
return res.status(401).send('Invalid id_token');
}
// Verify nonce matches the one in signed state
if (!payload.nonce || payload.nonce !== statePayload.nonce) {
logger.error('Nonce mismatch', {
tokenNonce: payload.nonce,
stateNonce: statePayload.nonce
});
return res.status(401).send('Nonce mismatch');
}
// Clear the optional nonce cookie if it was set
res.clearCookie('rp_nonce', getCookieOptions());
logger.log('ID token verified successfully', {
sub: payload.sub,
aud: payload.aud,
iss: payload.iss,
exp: payload.exp,
iat: payload.iat
});
// Step 6: Create opaque session (but don't set cookie yet)
const sessionId = crypto.randomBytes(24).toString('base64url');
// Store session data
await sessionService.createSession(sessionId, {
sub: payload.sub,
iat: payload.iat,
exp: payload.exp,
email: payload.email,
name: payload.name
});
// Step 7: Create transit token and store in Redis
// Transit token is separate from session ID for security
const transitToken = crypto.randomBytes(32).toString('base64url');
const transitKey = `login_tx:${transitToken}`;
// Store session ID with 60 second TTL
await redisClient.set(
transitKey,
sessionId,
'EX',
60 // 60 seconds TTL
);
logger.log('Transit token created', {
transitToken: transitToken.substring(0, 8) + '...',
sessionId: sessionId.substring(0, 8) + '...',
sub: payload.sub
});
// Step 8: Redirect to finalize endpoint with transit token
// This will set the cookie in first-party context
const finalizeUrl = `https://gateway.clockize.com/oidc/finalize?tx=${transitToken}`;
// Set security headers for the redirect
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Referrer-Policy', 'no-referrer');
logger.log('Redirecting to finalize endpoint', {
finalizeUrl: finalizeUrl.substring(0, 50) + '...'
});
// Return 303 See Other - the standard response for POST-redirect-GET
return res.redirect(303, finalizeUrl);
} catch (error) {
logger.error('Error processing OIDC callback', error);
return res.status(500).send('Internal server error');
}
});
/**
* GET /oidc/finalize
* Sets the session cookie in first-party context after successful OIDC callback
* Uses a transit token to retrieve the session ID from Redis
*/
router.get('/oidc/finalize', async (req: Request, res: Response) => {
try {
const { tx } = req.query;
if (!tx || typeof tx !== 'string') {
logger.error('Missing or invalid transit token');
return res.status(400).send('Missing or invalid transit token. Please retry login.');
}
// Retrieve and delete session ID from Redis (single-use)
const transitKey = `login_tx:${tx}`;
const sessionId = await redisClient.get(transitKey);
if (!sessionId) {
logger.error('Transit token not found or expired', { tx: tx.substring(0, 8) + '...' });
return res.status(400).send('Transit token expired or invalid. Please retry login.');
}
// Delete the transit token immediately (single-use)
await redisClient.del(transitKey);
logger.log('Transit token validated', {
tx: tx.substring(0, 8) + '...',
sessionId: sessionId.substring(0, 8) + '...'
});
// Verify session exists
const sessionData = await sessionService.getSession(sessionId);
if (!sessionData) {
logger.error('Session not found for transit token', { sessionId: sessionId.substring(0, 8) + '...' });
return res.status(400).send('Session not found. Please retry login.');
}
// NOW set the session cookie in first-party context
const cookieOptions = {
...getCookieOptions(),
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
};
logger.log('Setting session cookie in first-party context', {
name: sessionCookieName,
domain: cookieOptions.domain,
sameSite: cookieOptions.sameSite,
secure: cookieOptions.secure,
httpOnly: cookieOptions.httpOnly,
path: cookieOptions.path,
sessionId: sessionId.substring(0, 8) + '...'
});
res.cookie(sessionCookieName, sessionId, cookieOptions);
// Redirect to the app
const redirectUrl = `${rpFrontendUrl}/workspace`;
// Set security headers
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Referrer-Policy', 'no-referrer');
logger.log('Finalizing login, redirecting to app', {
redirectUrl,
sub: sessionData.sub
});
// 302 redirect to the app
return res.redirect(302, redirectUrl);
} catch (error) {
logger.error('Error in finalize endpoint', error);
return res.status(500).send('Internal server error');
}
});
/**
* GET /auth/debug
* Debug endpoint to check session and cookie status
* This endpoint does NOT require authentication
*/
router.get('/auth/debug', async (req: Request, res: Response) => {
const sessionId = req.cookies[sessionCookieName];
const debugInfo = {
timestamp: new Date().toISOString(),
cookies: {
hasCronideSession: !!sessionId,
sessionIdPrefix: sessionId ? sessionId.substring(0, 8) + '...' : null,
allCookies: Object.keys(req.cookies),
cookieHeader: req.headers.cookie
},
headers: {
origin: req.headers.origin,
referer: req.headers.referer,
host: req.headers.host,
userAgent: req.headers['user-agent']
},
session: {
exists: false,
data: null as any,
error: null as string | null
}
};
if (sessionId) {
try {
const sessionData = await sessionService.getSession(sessionId);
debugInfo.session.exists = !!sessionData;
if (sessionData) {
debugInfo.session.data = {
sub: sessionData.sub,
iat: sessionData.iat,
exp: sessionData.exp,
email: sessionData.email
};
}
} catch (error) {
debugInfo.session.error = error instanceof Error ? error.message : String(error);
}
}
logger.log('Debug endpoint accessed', debugInfo);
// Return HTML page with debug info
const html = `
<!DOCTYPE html>
<html>
<head>
<title>OIDC Debug Info</title>
<style>
body { font-family: monospace; padding: 20px; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; }
.success { color: green; }
.error { color: red; }
.warning { color: orange; }
</style>
</head>
<body>
<h1>OIDC Authentication Debug</h1>
<h2>Session Status</h2>
<pre>${JSON.stringify(debugInfo, null, 2)}</pre>
<h2>Actions</h2>
<button onclick="checkAuth()">Check /auth/status</button>
<button onclick="window.location.href='/login'">Start Login</button>
<button onclick="window.location.href='/auth/logout'">Logout</button>
<h2>Auth Status Check Result</h2>
<pre id="authResult">Click "Check /auth/status" to test</pre>
<script>
async function checkAuth() {
const result = document.getElementById('authResult');
result.textContent = 'Checking...';
try {
const response = await fetch('/auth/status', {
credentials: 'include'
});
const data = await response.json();
result.textContent = JSON.stringify(data, null, 2);
result.className = data.authenticated ? 'success' : 'warning';
} catch (error) {
result.textContent = 'Error: ' + error.message;
result.className = 'error';
}
}
// Auto-check on load
setTimeout(checkAuth, 500);
</script>
</body>
</html>
`;
res.setHeader('Content-Type', 'text/html');
res.send(html);
});
/**
* GET /auth/status
* Checks authentication status and returns user information
* This endpoint is called by the frontend to check if user is authenticated
*/
router.get('/auth/status', async (req: Request, res: Response) => {
try {
const sessionId = req.cookies?.[sessionCookieName];
if (!sessionId) {
logger.log('No session cookie found in /auth/status');
return res.json({ authenticated: false });
}
logger.log('Checking session in /auth/status', {
sessionId: sessionId.substring(0, 8) + '...',
cookieName: sessionCookieName
});
const session = await sessionService.getSession(sessionId);
if (!session) {
logger.log('Session not found in /auth/status', {
sessionId: sessionId.substring(0, 8) + '...'
});
return res.json({ authenticated: false });
}
logger.log('Session found in /auth/status', {
sessionId: sessionId.substring(0, 8) + '...',
sub: session.sub
});
// Return authentication status with user info
return res.json({
authenticated: true,
user: {
id: session.sub,
UUID: session.sub,
email: session.email,
username: session.email,
name: session.name,
...session
}
});
} catch (error) {
logger.error('Error checking auth status', error);
return res.json({ authenticated: false });
}
});
/**
* GET/POST /auth/logout
* Destroys the session and clears cookies
*/
const logoutHandler = async (req: Request, res: Response) => {
try {
const sessionId = req.cookies?.[sessionCookieName];
if (sessionId) {
await sessionService.destroySession(sessionId);
}
res.clearCookie(sessionCookieName, getCookieOptions());
// For GET requests, redirect to login page
// For POST requests, return JSON
if (req.method === 'GET') {
return res.redirect('/login');
} else {
return res.json({ success: true });
}
} catch (error) {
logger.error('Error during logout', error);
if (req.method === 'GET') {
return res.redirect('/login');
} else {
return res.status(500).json({ error: 'Logout failed' });
}
}
};
router.get('/auth/logout', logoutHandler);
router.post('/auth/logout', logoutHandler);
return router;
}

@ -0,0 +1,34 @@
import { LoadDictElement } from 'di-why/build/src/DiContainer';
import { Router } from 'express';
import { createOIDCRoutes } from './OIDCRoutes';
import { OIDCConfig } from '../types/auth.types';
export const oidcRoutesLDEGen = (config?: Partial<OIDCConfig>): LoadDictElement<Router> => ({
factory: ({ logger, sessionService, redisClient, appConfig }) => {
const finalConfig = {
logger,
sessionService,
redisClient,
issuer: config?.issuer || appConfig.swissoidIssuer,
clientId: config?.clientId || appConfig.swissoidClientId,
clientSecret: config?.clientSecret || appConfig.swissoidClientSecret,
tokenEndpoint: config?.tokenEndpoint || appConfig.swissoidTokenEndpoint,
jwksUri: config?.jwksUri || appConfig.swissoidJwksUri,
authorizeEndpoint: config?.authorizeEndpoint || appConfig.swissoidAuthorizeEndpoint,
callbackUrl: config?.callbackUrl || appConfig.rpCallbackUrl,
cookieDomain: config?.cookieDomain || appConfig.rpCookieDomain,
frontendUrl: config?.frontendUrl || appConfig.rpFrontendUrl,
sessionCookieName: config?.sessionCookieName || appConfig.sessionCookieName,
sessionSecret: config?.sessionSecret || appConfig.sessionSecret,
stateSigningSecret: config?.stateSigningSecret || appConfig.stateSigningSecret
};
return createOIDCRoutes(finalConfig);
},
locateDeps: {
logger: 'logger',
sessionService: 'sessionService',
redisClient: 'redisClient',
appConfig: 'appConfig'
}
});

@ -0,0 +1,46 @@
import { LoadDictElement } from 'di-why/build/src/DiContainer';
import { Express } from 'express';
import { createOIDCRoutes } from './OIDCRoutes';
import { OIDCConfig } from '../types/auth.types';
/**
* Loader that creates OIDC routes and mounts them on Express app
* Returns a string to indicate completion (cronide-user pattern)
*/
export const oidcRoutesMounterLDEGen = (config?: Partial<OIDCConfig>): LoadDictElement<string> => ({
factory: ({ app, logger, sessionService, redisClient, appConfig }) => {
const finalConfig = {
logger,
sessionService,
redisClient,
issuer: config?.issuer || appConfig.swissoidIssuer,
clientId: config?.clientId || appConfig.swissoidClientId,
clientSecret: config?.clientSecret || appConfig.swissoidClientSecret,
tokenEndpoint: config?.tokenEndpoint || appConfig.swissoidTokenEndpoint,
jwksUri: config?.jwksUri || appConfig.swissoidJwksUri,
authorizeEndpoint: config?.authorizeEndpoint || appConfig.swissoidAuthorizeEndpoint,
callbackUrl: config?.callbackUrl || appConfig.rpCallbackUrl,
cookieDomain: config?.cookieDomain || appConfig.rpCookieDomain || appConfig.cookieDomain,
frontendUrl: config?.frontendUrl || appConfig.rpFrontendUrl,
sessionCookieName: config?.sessionCookieName || appConfig.sessionCookieName,
sessionSecret: config?.sessionSecret || appConfig.sessionSecret,
stateSigningSecret: config?.stateSigningSecret || appConfig.stateSigningSecret
};
const router = createOIDCRoutes(finalConfig);
// Mount the routes on the Express app
(app as Express).use(router);
logger.log('Standard OIDC routes mounted - /login, POST /oidc/callback, /auth/status, /auth/userinfo, /auth/logout');
return 'oidcStandardRoutes-loaded';
},
locateDeps: {
app: 'app',
logger: 'logger',
sessionService: 'sessionService',
appConfig: 'appConfig',
redisClient: 'redisClient'
}
});

@ -0,0 +1,198 @@
import * as crypto from 'crypto';
import { SessionData } from '../types/auth.types';
/**
* SwissOID Session Service for managing user sessions
* Uses Redis for distributed session storage
*/
export class SwissOIDSessionService {
private readonly SESSION_PREFIX = 'session:';
private readonly DEFAULT_TTL = 7 * 24 * 60 * 60; // 7 days in seconds
constructor(
private redisClient: any,
private logger: any
) {}
/**
* Create a new session
*/
async createSession(sessionId: string, data: SessionData): Promise<void> {
const sessionKey = `${this.SESSION_PREFIX}${sessionId}`;
const now = Date.now();
const sessionData: SessionData = {
...data,
createdAt: now,
lastAccessedAt: now
};
// Calculate TTL based on exp if provided, otherwise use default
const ttl = data.exp
? Math.min(data.exp - Math.floor(now / 1000), this.DEFAULT_TTL)
: this.DEFAULT_TTL;
await this.redisClient.setex(
sessionKey,
ttl,
JSON.stringify(sessionData)
);
this.logger.log('Session created', {
sessionId: this.hashSessionId(sessionId),
sub: data.sub,
ttl
});
}
/**
* Get session data
*/
async getSession(sessionId: string): Promise<SessionData | null> {
const sessionKey = `${this.SESSION_PREFIX}${sessionId}`;
const dataStr = await this.redisClient.get(sessionKey);
if (!dataStr) {
return null;
}
try {
const data: SessionData = JSON.parse(dataStr);
// Check if session has expired based on exp field
if (data.exp && data.exp < Math.floor(Date.now() / 1000)) {
this.logger.warn('Session expired', {
sessionId: this.hashSessionId(sessionId),
exp: data.exp
});
await this.destroySession(sessionId);
return null;
}
// Update last accessed time
data.lastAccessedAt = Date.now();
const ttl = await this.redisClient.ttl(sessionKey);
if (ttl > 0) {
await this.redisClient.setex(
sessionKey,
ttl,
JSON.stringify(data)
);
}
return data;
} catch (error) {
this.logger.error('Failed to parse session data', {
sessionId: this.hashSessionId(sessionId),
error
});
return null;
}
}
/**
* Refresh a session's TTL
*/
async refreshSession(sessionId: string): Promise<boolean> {
const sessionKey = `${this.SESSION_PREFIX}${sessionId}`;
const dataStr = await this.redisClient.get(sessionKey);
if (!dataStr) {
return false;
}
try {
const data: SessionData = JSON.parse(dataStr);
data.lastAccessedAt = Date.now();
// Extend TTL
await this.redisClient.setex(
sessionKey,
this.DEFAULT_TTL,
JSON.stringify(data)
);
this.logger.log('Session refreshed', {
sessionId: this.hashSessionId(sessionId)
});
return true;
} catch (error) {
this.logger.error('Failed to refresh session', {
sessionId: this.hashSessionId(sessionId),
error
});
return false;
}
}
/**
* Destroy a session
*/
async destroySession(sessionId: string): Promise<void> {
const sessionKey = `${this.SESSION_PREFIX}${sessionId}`;
const deleted = await this.redisClient.del(sessionKey);
if (deleted > 0) {
this.logger.log('Session destroyed', {
sessionId: this.hashSessionId(sessionId)
});
}
}
/**
* List all sessions for a user
*/
async getUserSessions(sub: string): Promise<string[]> {
const pattern = `${this.SESSION_PREFIX}*`;
const keys = await this.redisClient.keys(pattern);
const userSessions: string[] = [];
for (const key of keys) {
const dataStr = await this.redisClient.get(key);
if (dataStr) {
try {
const data: SessionData = JSON.parse(dataStr);
if (data.sub === sub) {
const sessionId = key.replace(this.SESSION_PREFIX, '');
userSessions.push(sessionId);
}
} catch (error) {
// Skip invalid sessions
}
}
}
return userSessions;
}
/**
* Destroy all sessions for a user
*/
async destroyUserSessions(sub: string): Promise<number> {
const sessions = await this.getUserSessions(sub);
let count = 0;
for (const sessionId of sessions) {
await this.destroySession(sessionId);
count++;
}
this.logger.log('User sessions destroyed', {
sub,
count
});
return count;
}
/**
* Hash session ID for logging (security)
*/
private hashSessionId(sessionId: string): string {
return crypto.createHash('sha256')
.update(sessionId)
.digest('hex')
.substring(0, 8);
}
}

@ -0,0 +1,13 @@
import { LoadDictElement } from 'di-why/build/src/DiContainer';
import { SwissOIDSessionService } from './SwissOIDSessionService';
import { SwissOIDSessionConfig } from '../types/auth.types';
export const swissoidSessionServiceLDEGen = (config?: Partial<SwissOIDSessionConfig>): LoadDictElement<SwissOIDSessionService> => ({
factory: ({ redisClient, logger }) => {
return new SwissOIDSessionService(redisClient, logger);
},
locateDeps: {
redisClient: 'redisClient',
logger: 'logger'
}
});

@ -0,0 +1,46 @@
import { LoadDictElement } from 'di-why/build/src/DiContainer';
import Redis from 'ioredis';
export const redisClientLDEGen = (config?: {
url?: string;
host?: string;
port?: number;
password?: string;
db?: number;
}): LoadDictElement<Redis> => ({
factory: ({ appConfig, logger }) => {
const redisUrl = config?.url || appConfig.redisUrl || 'redis://localhost:6379';
const redis = new Redis(redisUrl, {
host: config?.host,
port: config?.port,
password: config?.password,
db: config?.db,
retryStrategy: (times) => {
if (times > 3) {
logger.error('Redis connection failed after 3 retries');
return null;
}
return Math.min(times * 200, 2000);
}
});
redis.on('connect', () => {
logger.info('Redis connected');
});
redis.on('error', (err) => {
logger.error('Redis error:', err);
});
redis.on('close', () => {
logger.warn('Redis connection closed');
});
return redis;
},
locateDeps: {
appConfig: 'appConfig',
logger: 'logger'
}
});

@ -0,0 +1,60 @@
export interface OIDCConfig {
// SwissOID settings
issuer: string; // https://api.swissoid.com
clientId: string; // Application client ID
clientSecret?: string; // For confidential clients
tokenEndpoint: string; // https://api.swissoid.com/token
jwksUri: string; // https://api.swissoid.com/.well-known/jwks.json
authorizeEndpoint: string; // https://api.swissoid.com/authorize
// RP settings
callbackUrl: string; // Where SwissOID redirects back to
cookieDomain: string; // Domain for cookies (e.g., .example.com)
frontendUrl: string; // Frontend application URL
// Session settings
sessionCookieName: string; // Name for session cookie
sessionSecret: string; // Secret for session encryption
stateSigningSecret: string; // Secret for signing state parameter
}
export interface CookieConfig {
domain: string;
sessionName: string;
refreshName?: string;
secureCookie?: boolean;
sameSite?: 'strict' | 'lax' | 'none';
httpOnly?: boolean;
sessionTTL?: number; // In seconds
refreshTTL?: number; // In seconds
}
export interface SessionData {
sub: string; // User ID from IdP
email?: string; // User email
name?: string; // User name
iat: number; // Issued at timestamp
exp: number; // Expiration timestamp
createdAt?: number; // Session creation time
lastAccessedAt?: number; // Last access time
metadata?: Record<string, any>; // Additional metadata
}
export interface DATClaims {
sub: string; // User ID
email?: string;
roles?: string[];
scopes?: string[];
tenant?: string;
sessionId?: string;
}
export interface SwissOIDSessionConfig {
cookieDomain: string;
cookieName: string;
refreshCookieName?: string;
secureCookie?: boolean;
sameSite?: 'strict' | 'lax' | 'none';
sessionTTL?: number; // In seconds
refreshTTL?: number; // In seconds
}

@ -0,0 +1,6 @@
import { AppConfigNamespace } from 'di-why';
export const swissoidAppConfigMapNamespace: AppConfigNamespace = {
namespace: 'swissoidAppConfigMap',
priority: 60,
};

@ -0,0 +1,16 @@
import { createAppConfigMerger } from 'di-why';
import swissoidAppConfigMap from '../config/appConfigMap';
/**
* Creates a merger that combines swissoid-back's authentication appConfigMap with a custom one.
* This allows consumers to extend the authentication configuration while maintaining type safety.
*
* Usage:
* ```typescript
* const mergedConfig = swissoidMergeAppConfigMap(myCustomAppConfigMap);
* export type AppConfig = ReturnType<typeof mergedConfig>;
* ```
*/
export const swissoidMergeAppConfigMap = createAppConfigMerger(swissoidAppConfigMap);
export default swissoidMergeAppConfigMap;

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
Loading…
Cancel
Save