feat: seems migrated
parent
f66c3d08c3
commit
157b11d45d
@ -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
|
||||
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…
Reference in New Issue