From 96f1cbb601c2cb3c262a19e438b6590075bfa976 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 Aug 2025 13:47:25 +0000 Subject: [PATCH 1/3] Initial plan From 4b9e1348d851aaa18ed03064fc455d391aaaf5cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:04:05 +0000 Subject: [PATCH 2/3] Implement CAS protocol support with core functionality Co-authored-by: thezzisu <21094314+thezzisu@users.noreply.github.com> --- docs/cas-protocol.md | 126 +++++++++ packages/server/src/api/session/index.ts | 11 + packages/server/src/cas/_common.ts | 269 ++++++++++++++++++++ packages/server/src/cas/index.ts | 50 ++++ packages/server/src/db/model/token.ts | 5 + packages/server/src/index.ts | 5 + packages/ui/app/composables/useAuthorize.ts | 82 +++++- 7 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 docs/cas-protocol.md create mode 100644 packages/server/src/cas/_common.ts create mode 100644 packages/server/src/cas/index.ts diff --git a/docs/cas-protocol.md b/docs/cas-protocol.md new file mode 100644 index 0000000..997a23b --- /dev/null +++ b/docs/cas-protocol.md @@ -0,0 +1,126 @@ +# CAS Protocol Implementation + +This document describes the Central Authentication Service (CAS) protocol implementation in UAAA. + +## Overview + +CAS is a single sign-on protocol that allows web applications to authenticate users without handling user credentials directly. The UAAA CAS implementation follows the [CAS Protocol Specification](https://apereo.github.io/cas/6.6.x/protocol/CAS-Protocol-Specification.html). + +## CAS Endpoints + +### Login Endpoint +- **URL**: `/cas/login` +- **Methods**: GET, POST +- **Parameters**: + - `service` (required): The URL of the service requesting authentication + - `renew` (optional): Force fresh authentication + - `gateway` (optional): Gateway mode for passive authentication + +**Example**: +``` +GET /cas/login?service=https://example.com/app +``` + +### Service Validate Endpoint +- **URL**: `/cas/serviceValidate` +- **Method**: GET +- **Parameters**: + - `service` (required): The service URL that was provided to login + - `ticket` (required): The service ticket to validate + - `format` (optional): Response format (`JSON` or default XML) + +**Example**: +``` +GET /cas/serviceValidate?service=https://example.com/app&ticket=ST-1234567890 +``` + +### Service Validate v3 Endpoint +- **URL**: `/cas/p3/serviceValidate` +- **Method**: GET +- **Parameters**: Same as `/cas/serviceValidate` + +### Logout Endpoint +- **URL**: `/cas/logout` +- **Methods**: GET, POST +- **Parameters**: + - `service` (optional): URL to redirect after logout + +**Example**: +``` +GET /cas/logout?service=https://example.com/app +``` + +## CAS Flow + +1. **Initial Request**: User accesses a CAS-enabled service +2. **Redirect to CAS**: Service redirects user to `/cas/login?service=` +3. **Authentication**: User authenticates with UAAA (if not already authenticated) +4. **Service Ticket**: CAS generates a service ticket and redirects back to service with ticket +5. **Ticket Validation**: Service validates the ticket by calling `/cas/serviceValidate` +6. **Access Granted**: If ticket is valid, service grants access to user + +## Response Formats + +### Success Response (XML) +```xml + + + + username + + user@example.com + John Doe + + + +``` + +### Success Response (JSON) +```json +{ + "serviceResponse": { + "authenticationSuccess": { + "user": "username", + "attributes": { + "email": "user@example.com", + "name": "John Doe" + } + } + } +} +``` + +### Error Response (XML) +```xml + + + + Ticket not found or expired + + +``` + +## Configuration + +### Service URL Validation +For production deployments, it's recommended to configure service URL validation to only allow trusted services. The current implementation accepts any valid HTTPS URL. + +### User Attributes +The CAS implementation returns user claims as attributes in the validation response. The available attributes depend on the user's configured claims and the security level of the authentication. + +## Integration with UAAA + +The CAS implementation integrates seamlessly with UAAA's existing authentication and authorization system: + +- Uses UAAA's session management for user authentication +- Leverages UAAA's security levels and claim system +- Integrates with the UI authorization flow +- Supports remote authorization (QR code authentication) + +## Security Considerations + +- Service tickets have a 5-minute expiration time +- Service tickets are single-use (consumed upon validation) +- Service URL validation should be implemented for production +- All CAS endpoints support HTTPS +- User attributes are filtered based on security level and application permissions \ No newline at end of file diff --git a/packages/server/src/api/session/index.ts b/packages/server/src/api/session/index.ts index 0f47b2a..5a68797 100644 --- a/packages/server/src/api/session/index.ts +++ b/packages/server/src/api/session/index.ts @@ -110,6 +110,17 @@ export const sessionApi = new Hono() return ctx.json(await session.derive(ctx.var.token, ctx.req.valid('json'), {})) } ) + .post( + '/cas_ticket', + verifyPermission({ path: '/session/derive' }), + arktypeValidator('json', type({ service: 'string', appId: 'string' })), + async (ctx) => { + const { service, appId } = ctx.req.valid('json') + const { cas } = ctx.var.app + const ticket = await cas.generateServiceTicket(ctx, service, ctx.var.token.sub) + return ctx.json({ ticket }) + } + ) .post( '/try_derive', verifyPermission({ path: '/session/derive' }), diff --git a/packages/server/src/cas/_common.ts b/packages/server/src/cas/_common.ts new file mode 100644 index 0000000..b9b92b1 --- /dev/null +++ b/packages/server/src/cas/_common.ts @@ -0,0 +1,269 @@ +import { type } from 'arktype' +import type { Context } from 'hono' +import { nanoid } from 'nanoid' +import type { App, ClaimName, IAppDoc, IUserClaims } from '../index.js' +import { BusinessError } from '../util/index.js' + +export interface ICASServiceValidateResponse { + user: string + attributes?: Record +} + +export class CASManager { + static tLoginRequest = type({ + service: 'string', + 'renew?': 'string', + 'gateway?': 'string' + }) + + static tServiceValidateRequest = type({ + service: 'string', + ticket: 'string', + 'format?': 'string' + }) + + static tLogoutRequest = type({ + 'service?': 'string', + 'url?': 'string' + }) + + private _base: string + + constructor(public app: App) { + this._base = app.config.get('deploymentUrl') + } + + /** + * Handle CAS login request - redirect to UI for authentication + */ + async loginToUI(ctx: Context, _request: Record) { + const request = CASManager.tLoginRequest(_request) + if (request instanceof type.errors) { + throw new BusinessError('BAD_REQUEST', { msg: 'Invalid CAS login request' }) + } + + const { service, renew, gateway } = request + + // Validate service URL + await this._validateServiceUrl(service) + + // Build authorize URL with CAS-specific parameters + const authorizeUrl = new URL(`${this._base}/ui/authorize`) + authorizeUrl.searchParams.set('type', 'cas') + authorizeUrl.searchParams.set('appId', 'cas') // Use 'cas' as special app identifier + authorizeUrl.searchParams.set('securityLevel', '1') + authorizeUrl.searchParams.set('params', JSON.stringify({ service })) + if (renew) authorizeUrl.searchParams.set('renew', renew) + if (gateway) authorizeUrl.searchParams.set('gateway', gateway) + + return authorizeUrl.toString() + } + + /** + * Handle CAS logout request + */ + async logoutToUI(ctx: Context, _request: Record) { + const request = CASManager.tLogoutRequest(_request) + if (request instanceof type.errors) { + throw new BusinessError('BAD_REQUEST', { msg: 'Invalid CAS logout request' }) + } + + const { service } = request + + // Build logout URL + const logoutUrl = new URL(`${this._base}/ui/logout`) + logoutUrl.searchParams.set('type', 'cas') + if (service) logoutUrl.searchParams.set('service', service) + + return logoutUrl.toString() + } + + /** + * Generate CAS service ticket + */ + async generateServiceTicket(ctx: Context, service: string, userId: string): Promise { + const { db } = this.app + const { tokens } = db + + // Generate ticket with CAS prefix + const ticket = `ST-${nanoid(32)}` + + // Store ticket in database with service and user info + await tokens.insertOne({ + _id: nanoid(), + ticket, + service, + userId, + type: 'cas_ticket', + appId: 'cas', // Use 'cas' as a special app identifier + sessionId: ctx.var.token?.sid || '', + permissions: [], + securityLevel: ctx.var.token?.level || 'LOW', + terminated: false, + createdAt: Date.now(), + updatedAt: Date.now(), + expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes expiry + activatedAt: Date.now(), + refreshTimeout: 0, + jwtTimeout: 0, + environment: { + ip: ctx.req.header('x-forwarded-for') || ctx.req.header('x-real-ip') || 'unknown', + ua: ctx.req.header('user-agent') || 'unknown' + } + } as any) + + return ticket + } + + /** + * Validate CAS service ticket and return user info + */ + async serviceValidate(ctx: Context, _request: Record): Promise { + const request = CASManager.tServiceValidateRequest(_request) + if (request instanceof type.errors) { + return this._buildErrorResponse('INVALID_REQUEST', 'Invalid request parameters') + } + + const { service, ticket, format } = request + + try { + // Validate service URL + await this._validateServiceUrl(service) + + // Find and consume ticket + const { db } = this.app + const { tokens, sessions } = db + + const tokenDoc = await tokens.findOneAndDelete({ + ticket, + service, + type: 'cas_ticket', + terminated: { $ne: true }, + expiresAt: { $gt: Date.now() } + }) + + if (!tokenDoc) { + return this._buildErrorResponse('INVALID_TICKET', 'Ticket not found or expired') + } + + // Get user session and claims + const session = await sessions.findOne({ + _id: tokenDoc.sessionId, + terminated: { $ne: true } + }) + + if (!session) { + return this._buildErrorResponse('INVALID_TICKET', 'Session not found') + } + + // Get user and claims + const user = await this.app.db.users.findOne({ _id: tokenDoc.userId }) + if (!user) { + return this._buildErrorResponse('INVALID_TICKET', 'User not found') + } + + // Get user claims for attributes + const claims = await this.app.claim.filterBasicClaims(ctx, user.claims || {}) + + // Build success response + return this._buildSuccessResponse(tokenDoc.userId, claims, format) + + } catch (error) { + return this._buildErrorResponse('INTERNAL_ERROR', 'Internal server error') + } + } + + /** + * Validate service URL against configured allowed services + */ + private async _validateServiceUrl(service: string) { + // For now, allow any HTTPS URL. In production, this should check against + // a whitelist of allowed service URLs configured per application + try { + const url = new URL(service) + if (!['http:', 'https:'].includes(url.protocol)) { + throw new BusinessError('BAD_REQUEST', { msg: 'Invalid service URL protocol' }) + } + } catch { + throw new BusinessError('BAD_REQUEST', { msg: 'Invalid service URL' }) + } + } + + /** + * Build CAS success response XML + */ + private _buildSuccessResponse(user: string, claims: Partial, format?: string): string { + const attributes = this._buildAttributes(claims) + + if (format === 'JSON') { + return JSON.stringify({ + serviceResponse: { + authenticationSuccess: { + user, + attributes + } + } + }) + } + + // Default XML format + let xml = '\n' + xml += '\n' + xml += ' \n' + xml += ` ${this._escapeXml(user)}\n` + + if (Object.keys(attributes).length > 0) { + xml += ' \n' + for (const [key, value] of Object.entries(attributes)) { + xml += ` ${this._escapeXml(value)}\n` + } + xml += ' \n' + } + + xml += ' \n' + xml += '' + + return xml + } + + /** + * Build CAS error response XML + */ + private _buildErrorResponse(code: string, description: string): string { + let xml = '\n' + xml += '\n' + xml += ` \n` + xml += ` ${this._escapeXml(description)}\n` + xml += ' \n' + xml += '' + + return xml + } + + /** + * Convert user claims to CAS attributes + */ + private _buildAttributes(claims: Partial): Record { + const attributes: Record = {} + + for (const [key, claim] of Object.entries(claims)) { + if (claim?.value && typeof claim.value === 'string') { + attributes[key] = claim.value + } + } + + return attributes + } + + /** + * Escape XML special characters + */ + private _escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } +} \ No newline at end of file diff --git a/packages/server/src/cas/index.ts b/packages/server/src/cas/index.ts new file mode 100644 index 0000000..500a3ef --- /dev/null +++ b/packages/server/src/cas/index.ts @@ -0,0 +1,50 @@ +import { Hono } from 'hono' +import { arktypeValidator } from '@hono/arktype-validator' +import { type } from 'arktype' + +export const casRouter = new Hono() + // CAS Login endpoint + // Specification: https://apereo.github.io/cas/6.6.x/protocol/CAS-Protocol-Specification.html#login + .get('/login', async (ctx) => { + const url = await ctx.var.app.cas.loginToUI(ctx, ctx.req.query()) + return ctx.redirect(url) + }) + .post('/login', arktypeValidator('form', type('Record')), async (ctx) => { + const url = await ctx.var.app.cas.loginToUI(ctx, ctx.req.valid('form')) + return ctx.redirect(url) + }) + + // CAS Service Validate endpoint + // Specification: https://apereo.github.io/cas/6.6.x/protocol/CAS-Protocol-Specification.html#service-validate + .get('/serviceValidate', async (ctx) => { + const response = await ctx.var.app.cas.serviceValidate(ctx, ctx.req.query()) + + // Determine content type based on format parameter + const format = ctx.req.query('format') + const contentType = format === 'JSON' ? 'application/json' : 'application/xml' + + ctx.header('Content-Type', `${contentType}; charset=utf-8`) + return ctx.body(response) + }) + + // CAS Logout endpoint + // Specification: https://apereo.github.io/cas/6.6.x/protocol/CAS-Protocol-Specification.html#logout + .get('/logout', async (ctx) => { + const url = await ctx.var.app.cas.logoutToUI(ctx, ctx.req.query()) + return ctx.redirect(url) + }) + .post('/logout', arktypeValidator('form', type('Record')), async (ctx) => { + const url = await ctx.var.app.cas.logoutToUI(ctx, ctx.req.valid('form')) + return ctx.redirect(url) + }) + + // CAS v3 Service Validate endpoint (alias for serviceValidate with extended features) + .get('/p3/serviceValidate', async (ctx) => { + const response = await ctx.var.app.cas.serviceValidate(ctx, ctx.req.query()) + + const format = ctx.req.query('format') + const contentType = format === 'JSON' ? 'application/json' : 'application/xml' + + ctx.header('Content-Type', `${contentType}; charset=utf-8`) + return ctx.body(response) + }) \ No newline at end of file diff --git a/packages/server/src/db/model/token.ts b/packages/server/src/db/model/token.ts index 964c81a..d0dac56 100644 --- a/packages/server/src/db/model/token.ts +++ b/packages/server/src/db/model/token.ts @@ -89,5 +89,10 @@ export interface ITokenDoc { challenge?: string | undefined code?: string | undefined + /** CAS related */ + ticket?: string | undefined + service?: string | undefined + type?: 'cas_ticket' | undefined + environment: ITokenEnvironment } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index dfc9ab5..418fb1a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -9,6 +9,8 @@ import { CredentialManager } from './credential/index.js' import { DbManager } from './db/index.js' import { OAuthManager } from './oauth/_common.js' import { oauthRouter, oauthWellKnownRouter } from './oauth/index.js' +import { CASManager } from './cas/_common.js' +import { casRouter } from './cas/index.js' import { PluginManager } from './plugin/index.js' import { SessionManager } from './session/index.js' import { TokenManager } from './token/index.js' @@ -33,6 +35,7 @@ export class App extends Hookable<{ token session oauth + cas server?: ReturnType @@ -51,6 +54,7 @@ export class App extends Hookable<{ this.token = new TokenManager(this) this.session = new SessionManager(this) this.oauth = new OAuthManager(this) + this.cas = new CASManager(this) } async init() { @@ -98,6 +102,7 @@ export class App extends Hookable<{ }) .route('/api', rootApi) .route('/oauth', oauthRouter) + .route('/cas', casRouter) .route('/.well-known', new Hono().route('/', oauthWellKnownRouter)) await this.callHook('extendApp', app) this.server = serve({ diff --git a/packages/ui/app/composables/useAuthorize.ts b/packages/ui/app/composables/useAuthorize.ts index bb3b790..b21e25b 100644 --- a/packages/ui/app/composables/useAuthorize.ts +++ b/packages/ui/app/composables/useAuthorize.ts @@ -20,6 +20,85 @@ abstract class Connector { abstract onCancel(params: IAuthorizeParams, app: IAppDTO): Promise } +class CASConnector extends Connector { + private _extractParams(params: IAuthorizeParams) { + const service = params.params?.service as string + if (!service) throw new Error('Missing service parameter') + return { service } + } + + override async preAuthorize(params: IAuthorizeParams, app: IAppDoc): Promise { + const { service } = this._extractParams(params) + // Validate service URL - could add service URL validation here + try { + new URL(service) + } catch { + throw new Error('Invalid service URL') + } + } + + override async onAuthorize( + params: IAuthorizeParams, + app: IAppDoc, + beforeRedirect?: () => void + ): Promise { + const { service } = this._extractParams(params) + + // Generate CAS service ticket + const resp = await api.session.cas_ticket.$post({ + json: { + service, + appId: params.appId + } + }) + await api.checkResponse(resp) + const data = await resp.json() + + if (!('ticket' in data)) { + throw new Error('Invalid response') + } + + if (params.userCode) { + // Handle remote authorization for CAS + const resp = await api.session.remote_authorize.$post({ + json: { + userCode: params.userCode, + response: { + ticket: data.ticket + } + } + }) + await api.checkResponse(resp) + } else { + // Redirect to service with ticket + const serviceUrl = new URL(service) + serviceUrl.searchParams.set('ticket', data.ticket as string) + beforeRedirect?.() + await sleep(200) + location.href = serviceUrl.toString() + } + } + + override async onRemoteAuthorize( + params: IAuthorizeParams, + response: Record + ): Promise { + const { service } = this._extractParams(params) + if (!('ticket' in response) || typeof response.ticket !== 'string') { + throw new Error('Invalid response') + } + const serviceUrl = new URL(service) + serviceUrl.searchParams.set('ticket', response.ticket) + location.href = serviceUrl.toString() + } + + override async onCancel(params: IAuthorizeParams, app: IAppDoc): Promise { + const { service } = this._extractParams(params) + // For CAS, we just redirect back to service without ticket + location.href = service + } +} + class OpenIDConnector extends Connector { private _extractParams(params: IAuthorizeParams) { const response_type = params.params?.response_type as string @@ -121,7 +200,8 @@ class OpenIDConnector extends Connector { } const connectors = { - oidc: new OpenIDConnector() + oidc: new OpenIDConnector(), + cas: new CASConnector() } satisfies Record type ConnectorType = keyof typeof connectors From b6bea7e7c0eb9d275dfd25f75f4fb8c103d2fe13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:07:52 +0000 Subject: [PATCH 3/3] Add CAS protocol tests, examples, and documentation Co-authored-by: thezzisu <21094314+thezzisu@users.noreply.github.com> --- README.md | 10 ++ docs/cas-examples.md | 277 +++++++++++++++++++++++++++++ packages/server/src/cas/_common.ts | 16 +- packages/server/test/cas.test.js | 102 +++++++++++ 4 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 docs/cas-examples.md create mode 100644 packages/server/test/cas.test.js diff --git a/README.md b/README.md index 59a23f6..f304676 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,16 @@ +## Features + +UAAA provides comprehensive authentication and authorization services with support for multiple protocols: + +- **OAuth 2.0 / OpenID Connect**: Industry-standard protocols for secure API access and user authentication +- **CAS Protocol**: Central Authentication Service for single sign-on (SSO) in web applications +- **Multi-factor Authentication**: Support for TOTP, WebAuthn, and other authentication methods +- **Flexible User Management**: Comprehensive user lifecycle and permission management +- **Plugin Architecture**: Extensible system for custom authentication methods and integrations + ## Build Images ```sh diff --git a/docs/cas-examples.md b/docs/cas-examples.md new file mode 100644 index 0000000..dd596e5 --- /dev/null +++ b/docs/cas-examples.md @@ -0,0 +1,277 @@ +# CAS Protocol Examples + +This document provides examples of how to integrate with UAAA's CAS protocol implementation. + +## Example 1: Simple Web Application Integration + +### Step 1: Redirect User to CAS Login + +When a user accesses a protected resource in your application, redirect them to the CAS login endpoint: + +```javascript +// JavaScript example +const serviceUrl = encodeURIComponent('https://myapp.example.com/protected') +const casLoginUrl = `https://auth.example.com/cas/login?service=${serviceUrl}` +window.location.href = casLoginUrl +``` + +```python +# Python Flask example +from flask import redirect, request, url_for +from urllib.parse import quote + +@app.route('/protected') +def protected(): + if 'user' not in session: + service_url = quote(request.url) + cas_login_url = f"https://auth.example.com/cas/login?service={service_url}" + return redirect(cas_login_url) + return render_template('protected.html') +``` + +### Step 2: Handle CAS Callback + +When the user completes authentication, CAS will redirect back to your service with a ticket: + +```javascript +// JavaScript/Node.js example +app.get('/protected', async (req, res) => { + const ticket = req.query.ticket + + if (!ticket) { + // Redirect to CAS login + const serviceUrl = encodeURIComponent(req.originalUrl) + return res.redirect(`https://auth.example.com/cas/login?service=${serviceUrl}`) + } + + try { + // Validate ticket + const user = await validateCASTicket(ticket, req.originalUrl) + req.session.user = user + res.render('protected', { user }) + } catch (error) { + res.status(401).send('Authentication failed') + } +}) + +async function validateCASTicket(ticket, serviceUrl) { + const validateUrl = new URL('https://auth.example.com/cas/serviceValidate') + validateUrl.searchParams.set('service', serviceUrl.split('?')[0]) // Remove ticket parameter + validateUrl.searchParams.set('ticket', ticket) + validateUrl.searchParams.set('format', 'JSON') + + const response = await fetch(validateUrl) + const data = await response.json() + + if (data.serviceResponse.authenticationSuccess) { + return data.serviceResponse.authenticationSuccess.user + } else { + throw new Error('Ticket validation failed') + } +} +``` + +```python +# Python Flask example with XML parsing +import requests +import xml.etree.ElementTree as ET +from urllib.parse import quote, unquote + +@app.route('/protected') +def protected(): + ticket = request.args.get('ticket') + + if not ticket: + # Redirect to CAS login + service_url = quote(request.url) + cas_login_url = f"https://auth.example.com/cas/login?service={service_url}" + return redirect(cas_login_url) + + try: + # Validate ticket + user_info = validate_cas_ticket(ticket, request.url.split('?')[0]) + session['user'] = user_info['user'] + session['attributes'] = user_info['attributes'] + return render_template('protected.html', user=user_info) + except Exception as e: + return "Authentication failed", 401 + +def validate_cas_ticket(ticket, service_url): + validate_url = "https://auth.example.com/cas/serviceValidate" + params = { + 'service': service_url, + 'ticket': ticket + } + + response = requests.get(validate_url, params=params) + + if response.status_code == 200: + root = ET.fromstring(response.text) + + # Check for authentication success + success = root.find('.//{http://www.yale.edu/tp/cas}authenticationSuccess') + if success is not None: + user = success.find('.//{http://www.yale.edu/tp/cas}user').text + + # Extract attributes + attributes = {} + attrs_elem = success.find('.//{http://www.yale.edu/tp/cas}attributes') + if attrs_elem is not None: + for attr in attrs_elem: + attr_name = attr.tag.split('}')[-1] # Remove namespace + attributes[attr_name] = attr.text + + return {'user': user, 'attributes': attributes} + else: + # Authentication failed + failure = root.find('.//{http://www.yale.edu/tp/cas}authenticationFailure') + error_msg = failure.text if failure is not None else 'Unknown error' + raise Exception(f"CAS authentication failed: {error_msg}") + else: + raise Exception(f"CAS validation request failed: {response.status_code}") +``` + +## Example 2: Single Logout + +Implement CAS single logout to log users out of all CAS-enabled applications: + +```javascript +// JavaScript logout +function casLogout() { + const serviceUrl = encodeURIComponent('https://myapp.example.com/') + window.location.href = `https://auth.example.com/cas/logout?service=${serviceUrl}` +} +``` + +```python +# Python Flask logout +@app.route('/logout') +def logout(): + session.clear() + service_url = quote(url_for('index', _external=True)) + return redirect(f"https://auth.example.com/cas/logout?service={service_url}") +``` + +## Example 3: JSON Response Format + +For modern applications, you can request JSON responses instead of XML: + +```javascript +// Request JSON format +async function validateCASTicketJSON(ticket, serviceUrl) { + const validateUrl = new URL('https://auth.example.com/cas/serviceValidate') + validateUrl.searchParams.set('service', serviceUrl) + validateUrl.searchParams.set('ticket', ticket) + validateUrl.searchParams.set('format', 'JSON') + + const response = await fetch(validateUrl) + const data = await response.json() + + if (data.serviceResponse.authenticationSuccess) { + return { + user: data.serviceResponse.authenticationSuccess.user, + attributes: data.serviceResponse.authenticationSuccess.attributes || {} + } + } else { + const failure = data.serviceResponse.authenticationFailure + throw new Error(`CAS validation failed: ${failure.code} - ${failure.description}`) + } +} +``` + +## Example 4: Environment Configuration + +Configure your application to work with different CAS servers: + +```javascript +// environment.js +const config = { + development: { + casBaseUrl: 'http://localhost:3000/cas', + serviceUrl: 'http://localhost:8080' + }, + production: { + casBaseUrl: 'https://auth.company.com/cas', + serviceUrl: 'https://myapp.company.com' + } +} + +export default config[process.env.NODE_ENV || 'development'] +``` + +```python +# config.py +import os + +class Config: + CAS_BASE_URL = os.environ.get('CAS_BASE_URL', 'http://localhost:3000/cas') + SERVICE_URL = os.environ.get('SERVICE_URL', 'http://localhost:5000') + +class DevelopmentConfig(Config): + CAS_BASE_URL = 'http://localhost:3000/cas' + +class ProductionConfig(Config): + CAS_BASE_URL = 'https://auth.company.com/cas' +``` + +## Example 5: Error Handling + +Robust error handling for CAS integration: + +```javascript +class CASError extends Error { + constructor(code, description) { + super(`CAS Error ${code}: ${description}`) + this.code = code + this.description = description + } +} + +async function validateTicketWithErrorHandling(ticket, serviceUrl) { + try { + const response = await fetch(validateUrl) + + if (!response.ok) { + throw new CASError('HTTP_ERROR', `HTTP ${response.status}`) + } + + const data = await response.json() + + if (data.serviceResponse.authenticationSuccess) { + return data.serviceResponse.authenticationSuccess + } else { + const failure = data.serviceResponse.authenticationFailure + throw new CASError(failure.code, failure.description) + } + } catch (error) { + if (error instanceof CASError) { + throw error + } + throw new CASError('NETWORK_ERROR', 'Failed to communicate with CAS server') + } +} +``` + +## Security Best Practices + +1. **Always use HTTPS** in production +2. **Validate service URLs** on the CAS server +3. **Use tickets only once** (they are automatically consumed) +4. **Handle ticket expiration** (5-minute timeout) +5. **Implement proper session management** after successful authentication +6. **Log authentication events** for security monitoring + +## Testing + +Use tools like curl to test CAS endpoints: + +```bash +# Test login (will redirect to authorization UI) +curl -i "https://auth.example.com/cas/login?service=https://myapp.example.com/protected" + +# Test service validation (replace with actual ticket) +curl "https://auth.example.com/cas/serviceValidate?service=https://myapp.example.com/protected&ticket=ST-1234567890&format=JSON" + +# Test logout +curl -i "https://auth.example.com/cas/logout?service=https://myapp.example.com/" +``` \ No newline at end of file diff --git a/packages/server/src/cas/_common.ts b/packages/server/src/cas/_common.ts index b9b92b1..11cf02f 100644 --- a/packages/server/src/cas/_common.ts +++ b/packages/server/src/cas/_common.ts @@ -164,9 +164,10 @@ export class CASManager { // Get user claims for attributes const claims = await this.app.claim.filterBasicClaims(ctx, user.claims || {}) + const attributes = this._buildAttributes(claims) // Build success response - return this._buildSuccessResponse(tokenDoc.userId, claims, format) + return this._buildSuccessResponse(tokenDoc.userId, attributes, format) } catch (error) { return this._buildErrorResponse('INTERNAL_ERROR', 'Internal server error') @@ -190,10 +191,9 @@ export class CASManager { } /** - * Build CAS success response XML + * Build CAS success response XML (public for testing) */ - private _buildSuccessResponse(user: string, claims: Partial, format?: string): string { - const attributes = this._buildAttributes(claims) + _buildSuccessResponse(user: string, attributes: Record, format?: string): string { if (format === 'JSON') { return JSON.stringify({ @@ -227,9 +227,9 @@ export class CASManager { } /** - * Build CAS error response XML + * Build CAS error response XML (public for testing) */ - private _buildErrorResponse(code: string, description: string): string { + _buildErrorResponse(code: string, description: string): string { let xml = '\n' xml += '\n' xml += ` \n` @@ -256,9 +256,9 @@ export class CASManager { } /** - * Escape XML special characters + * Escape XML special characters (public for testing) */ - private _escapeXml(str: string): string { + _escapeXml(str: string): string { return str .replace(/&/g, '&') .replace(/ { + const config = { + deploymentUrl: 'https://auth.example.com' + } + return config[key] + } + }, + db: { + tokens: { + insertOne: async () => ({ insertedId: 'test-id' }), + findOneAndDelete: async () => ({ userId: 'test-user', sessionId: 'test-session' }) + }, + sessions: { + findOne: async () => ({ _id: 'test-session', userId: 'test-user' }) + }, + users: { + findOne: async () => ({ _id: 'test-user', claims: {} }) + } + }, + claim: { + filterBasicClaims: async () => ({}) + } +} + +describe('CAS Manager', () => { + let casManager + + beforeAll(() => { + casManager = new CASManager(mockApp) + }) + + it('should create CAS manager instance', () => { + expect(casManager).toBeDefined() + expect(casManager.app).toBe(mockApp) + }) + + it('should validate login request', () => { + const validRequest = { service: 'https://example.com/app' } + const result = CASManager.tLoginRequest(validRequest) + expect(result).toEqual(validRequest) + }) + + it('should reject invalid login request', () => { + const invalidRequest = { invalid: 'request' } + const result = CASManager.tLoginRequest(invalidRequest) + expect(result.constructor.name).toBe('ArkErrors') + }) + + it('should validate service validate request', () => { + const validRequest = { + service: 'https://example.com/app', + ticket: 'ST-1234567890' + } + const result = CASManager.tServiceValidateRequest(validRequest) + expect(result).toEqual(validRequest) + }) + + it('should build error response XML', () => { + const xml = casManager._buildErrorResponse('INVALID_TICKET', 'Test error') + expect(xml).toContain('') + expect(xml).toContain('') + expect(xml).toContain('Test error') + }) + + it('should build success response XML', () => { + const xml = casManager._buildSuccessResponse('testuser', { email: 'test@example.com' }) + expect(xml).toContain('') + expect(xml).toContain('') + expect(xml).toContain('testuser') + expect(xml).toContain('test@example.com') + }) + + it('should build success response JSON', () => { + const json = casManager._buildSuccessResponse('testuser', { email: 'test@example.com' }, 'JSON') + const parsed = JSON.parse(json) + expect(parsed.serviceResponse.authenticationSuccess.user).toBe('testuser') + expect(parsed.serviceResponse.authenticationSuccess.attributes.email).toBe('test@example.com') + }) + + it('should escape XML special characters', () => { + const xml = casManager._buildErrorResponse('TEST', 'Error with & "quotes"') + expect(xml).toContain('Error with <tags> & "quotes"') + }) + + it('should generate login URL', async () => { + const mockCtx = {} + const request = { service: 'https://example.com/app' } + + const url = await casManager.loginToUI(mockCtx, request) + expect(url).toContain('https://auth.example.com/ui/authorize') + expect(url).toContain('type=cas') + expect(url).toContain('appId=cas') + }) +}) \ No newline at end of file