OIDC checking / retrying

This commit is contained in:
simonredfern 2025-12-14 11:04:14 +01:00
parent ca923f7b5a
commit cd9ba264ec
4 changed files with 312 additions and 13 deletions

View File

@ -147,21 +147,36 @@ let instance: any
// Get OAuth2Service from container
const oauth2Service = Container.get(OAuth2Service)
// Initialize OAuth2 service from OIDC discovery document (await it!)
try {
await oauth2Service.initializeFromWellKnown(wellKnownUrl)
// Initialize OAuth2 service with retry logic
const isProduction = process.env.NODE_ENV === 'production'
const maxRetries = Infinity // Retry indefinitely
const initialDelay = 1000 // 1 second, then exponential backoff
console.log(
'Attempting OAuth2 initialization (will retry indefinitely with exponential backoff)...'
)
const success = await oauth2Service.initializeWithRetry(wellKnownUrl, maxRetries, initialDelay)
if (success) {
console.log('OAuth2Service: Initialization successful')
console.log(' Client ID:', process.env.VITE_OBP_OAUTH2_CLIENT_ID || 'NOT SET')
console.log(' Redirect URI:', process.env.VITE_OBP_OAUTH2_REDIRECT_URL || 'NOT SET')
console.log('OAuth2/OIDC ready for authentication')
} catch (error: any) {
console.error('OAuth2Service: Initialization failed:', error.message)
console.error('OAuth2/OIDC authentication will not be available')
console.error('Please check:')
console.error(' 1. OBP-OIDC server is running')
console.error(' 2. VITE_OBP_OAUTH2_WELL_KNOWN_URL is correct')
console.error(' 3. Network connectivity to OIDC provider')
console.warn('Server will start but OAuth2 authentication will fail.')
} else {
console.error('OAuth2Service: Initialization failed after all retries')
// Use graceful degradation for both development and production
const envMode = isProduction ? 'Production' : 'Development'
console.warn(`WARNING: ${envMode} mode: Server will start without OAuth2`)
console.warn('WARNING: Login will be unavailable until OIDC server is reachable')
console.warn('WARNING: Starting health check to reconnect automatically...')
console.warn('Please check:')
console.warn(' 1. OBP-OIDC server is running')
console.warn(' 2. VITE_OBP_OAUTH2_WELL_KNOWN_URL is correct')
console.warn(' 3. Network connectivity to OIDC provider')
// Start periodic health check to reconnect when OIDC becomes available
oauth2Service.startHealthCheck(1000) // Start with 1 second, then exponential backoff
}
}
console.log(`-----------------------------------------------------------------`)

View File

@ -32,6 +32,7 @@ import OBPClientService from '../services/OBPClientService.js'
import { Service, Container } from 'typedi'
import { OAuthConfig } from 'obp-typescript'
import { commitId } from '../app.js'
import { OAuth2Service } from '../services/OAuth2Service.js'
import {
RESOURCE_DOCS_API_VERSION,
MESSAGE_DOCS_API_VERSION,
@ -139,4 +140,75 @@ export class StatusController {
return false
}
}
@Get('/oauth2')
getOAuth2Status(@Res() response: Response): Response {
try {
const oauth2Service = Container.get(OAuth2Service)
const isInitialized = oauth2Service.isInitialized()
const oidcConfig = oauth2Service.getOIDCConfiguration()
const healthCheckActive = oauth2Service.isHealthCheckActive()
const healthCheckAttempts = oauth2Service.getHealthCheckAttempts()
return response.json({
available: isInitialized,
message: isInitialized
? 'OAuth2/OIDC is ready for authentication'
: 'OAuth2/OIDC is not available',
issuer: oidcConfig?.issuer || null,
authorizationEndpoint: oidcConfig?.authorization_endpoint || null,
wellKnownUrl: process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL || null,
healthCheck: {
active: healthCheckActive,
attempts: healthCheckAttempts
}
})
} catch (error) {
return response.status(500).json({
available: false,
message: 'Error checking OAuth2 status',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
@Get('/oauth2/reconnect')
async reconnectOAuth2(@Res() response: Response): Promise<Response> {
try {
const oauth2Service = Container.get(OAuth2Service)
if (oauth2Service.isInitialized()) {
return response.json({
success: true,
message: 'OAuth2 is already connected',
alreadyConnected: true
})
}
const wellKnownUrl = process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL
if (!wellKnownUrl) {
return response.status(400).json({
success: false,
message: 'VITE_OBP_OAUTH2_WELL_KNOWN_URL not configured'
})
}
console.log('Manual OAuth2 reconnection attempt triggered...')
await oauth2Service.initializeFromWellKnown(wellKnownUrl)
console.log('Manual OAuth2 reconnection successful!')
return response.json({
success: true,
message: 'OAuth2 reconnection successful',
issuer: oauth2Service.getOIDCConfiguration()?.issuer || null
})
} catch (error) {
console.error('Manual OAuth2 reconnection failed:', error)
return response.status(500).json({
success: false,
message: 'OAuth2 reconnection failed',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
}

View File

@ -122,6 +122,10 @@ export class OAuth2Service {
private readonly clientSecret: string
private readonly redirectUri: string
private initialized: boolean = false
private wellKnownUrl: string = ''
private healthCheckInterval: NodeJS.Timeout | null = null
private healthCheckAttempts: number = 0
private healthCheckActive: boolean = false
constructor() {
// Load OAuth2 configuration from environment
@ -161,6 +165,9 @@ export class OAuth2Service {
async initializeFromWellKnown(wellKnownUrl: string): Promise<void> {
console.log('OAuth2Service: Fetching OIDC configuration from:', wellKnownUrl)
// Store the well-known URL for potential retries
this.wellKnownUrl = wellKnownUrl
try {
const response = await fetch(wellKnownUrl)
@ -203,6 +210,151 @@ export class OAuth2Service {
}
}
/**
* Start periodic health check to reconnect if OIDC server becomes available
* Uses exponential backoff: 1min, 2min, 4min, capped at 4min
*
* @param {number} initialIntervalMs - Initial interval in milliseconds (default: 1000 = 1 second)
*
* @example
* oauth2Service.startHealthCheck(1000) // Start checking at 1 second, then exponential backoff
*/
startHealthCheck(initialIntervalMs: number = 1000): void {
if (this.healthCheckInterval) {
console.log('OAuth2Service: Health check already running')
return
}
if (!this.wellKnownUrl) {
console.warn('OAuth2Service: Cannot start health check - no well-known URL configured')
return
}
this.healthCheckAttempts = 0
this.healthCheckActive = true
console.log('OAuth2Service: Starting health check with exponential backoff')
const scheduleNextCheck = () => {
if (!this.initialized && this.wellKnownUrl) {
// Calculate delay with exponential backoff, capped at 4 minutes
const delay = Math.min(initialIntervalMs * Math.pow(2, this.healthCheckAttempts), 240000)
const delayDisplay =
delay < 60000
? `${(delay / 1000).toFixed(0)} second(s)`
: `${(delay / 60000).toFixed(1)} minute(s)`
console.log(
`OAuth2Service: Health check scheduled in ${delayDisplay} (attempt ${this.healthCheckAttempts + 1})`
)
this.healthCheckInterval = setTimeout(async () => {
console.log('OAuth2Service: Health check - attempting to reconnect to OIDC server...')
try {
await this.initializeFromWellKnown(this.wellKnownUrl)
console.log('OAuth2Service: Successfully reconnected to OIDC server!')
// Stop health check once reconnected
this.stopHealthCheck()
} catch (error) {
this.healthCheckAttempts++
// Schedule next check with longer interval
scheduleNextCheck()
}
}, delay)
}
}
// Start the first check
scheduleNextCheck()
}
/**
* Stop the periodic health check
*/
stopHealthCheck(): void {
if (this.healthCheckInterval) {
clearTimeout(this.healthCheckInterval)
this.healthCheckInterval = null
this.healthCheckAttempts = 0
this.healthCheckActive = false
console.log('OAuth2Service: Health check stopped')
}
}
/**
* Check if health check is currently active
*
* @returns {boolean} True if health check is running
*/
isHealthCheckActive(): boolean {
return this.healthCheckActive
}
/**
* Get the number of health check attempts so far
*
* @returns {number} Number of health check attempts
*/
getHealthCheckAttempts(): number {
return this.healthCheckAttempts
}
/**
* Attempt to initialize with exponential backoff retry (continues indefinitely)
*
* @param {number} maxRetries - Maximum number of retry attempts (default: Infinity for continuous retries)
* @param {string} wellKnownUrl - The .well-known/openid-configuration URL
* @param {number} maxRetries - Maximum number of retry attempts (default: Infinity for continuous retries)
* @param {number} initialDelayMs - Initial delay in milliseconds (default: 1000 = 1 second)
* @returns {Promise<boolean>} True if initialization succeeded, false if maxRetries reached
*
* @example
* const success = await oauth2Service.initializeWithRetry('http://localhost:9000/.well-known/openid-configuration', Infinity, 1000)
*/
async initializeWithRetry(
wellKnownUrl: string,
maxRetries: number = Infinity,
initialDelayMs: number = 1000
): Promise<boolean> {
if (!wellKnownUrl) {
console.error('OAuth2Service: Cannot retry - no well-known URL configured')
return false
}
// Store the well-known URL for retries and health checks
this.wellKnownUrl = wellKnownUrl
let attempt = 0
while (attempt < maxRetries) {
try {
await this.initializeFromWellKnown(wellKnownUrl)
console.log(`OAuth2Service: Initialized successfully on attempt ${attempt + 1}`)
return true
} catch (error: any) {
const delay = Math.min(initialDelayMs * Math.pow(2, attempt), 240000) // Cap at 4 minutes
const delayDisplay =
delay < 60000
? `${(delay / 1000).toFixed(0)} second(s)`
: `${(delay / 60000).toFixed(1)} minute(s)`
if (maxRetries === Infinity || attempt < maxRetries - 1) {
console.log(
`OAuth2Service: Attempt ${attempt + 1} failed. Retrying in ${delayDisplay}...`
)
await new Promise((resolve) => setTimeout(resolve, delay))
attempt++
} else {
console.error(
`OAuth2Service: Failed to initialize after ${maxRetries} attempts:`,
error.message
)
return false
}
}
}
return false
}
/**
* Check if the service is initialized and ready to use
*

View File

@ -26,7 +26,7 @@
-->
<script setup lang="ts">
import { ref, inject, watchEffect, onMounted, computed } from 'vue'
import { ref, inject, watchEffect, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { OBP_API_DEFAULT_RESOURCE_DOC_VERSION, getCurrentUser } from '../obp'
import { getOBPAPIVersions } from '../obp/api-version'
@ -74,10 +74,35 @@ const sortedVersions = computed(() => {
const isShowLoginButton = ref(true)
const isShowLogOffButton = ref(false)
const oauth2Available = ref(true) // Assume available initially
const oauth2StatusMessage = ref('')
const logo = ref(logoSource)
const headerLinksHoverColor = ref(headerLinksHoverColorSetting)
const headerLinksBackgroundColor = ref(headerLinksBackgroundColorSetting)
// Check OAuth2 availability
let oauth2CheckInterval: number | null = null
async function checkOAuth2Availability() {
try {
const response = await fetch('/api/status/oauth2')
const data = await response.json()
oauth2Available.value = data.available
oauth2StatusMessage.value = data.message || ''
if (data.available && oauth2CheckInterval) {
// Stop polling once OAuth2 is available
clearInterval(oauth2CheckInterval)
oauth2CheckInterval = null
console.log('OAuth2 is now available, stopped polling')
}
} catch (error) {
oauth2Available.value = false
oauth2StatusMessage.value = 'Failed to check OAuth2 status'
console.error('Error checking OAuth2 status:', error)
}
}
const clearActiveTab = () => {
const activeLinks = document.querySelectorAll<HTMLElement>('.router-link')
for (const active of activeLinks) {
@ -125,6 +150,15 @@ const handleMore = (command: string) => {
}
onMounted(async () => {
// Initial OAuth2 availability check
await checkOAuth2Availability()
// If OAuth2 is not available, poll every 30 seconds
if (!oauth2Available.value) {
console.log('OAuth2 not available, starting periodic check...')
oauth2CheckInterval = window.setInterval(checkOAuth2Availability, 30000)
}
const currentUser = await getCurrentUser()
const currentResponseKeys = Object.keys(currentUser)
if (currentResponseKeys.includes('username')) {
@ -137,6 +171,14 @@ onMounted(async () => {
}
})
onUnmounted(() => {
// Clean up polling interval
if (oauth2CheckInterval) {
clearInterval(oauth2CheckInterval)
oauth2CheckInterval = null
}
})
watchEffect(() => {
const routeName = typeof route.name === 'string' ? route.name : null
if (routeName && route.params && !route.params.id) {
@ -202,7 +244,12 @@ const getCurrentPath = () => {
<arrow-down />
</el-icon>
</span>-->
<a v-bind:href="'/api/oauth2/connect?redirect='+ encodeURIComponent(getCurrentPath())" v-show="isShowLoginButton" class="login-button router-link" id="login">
<el-tooltip v-if="isShowLoginButton && !oauth2Available" :content="oauth2StatusMessage || 'OAuth2 server not available'" placement="bottom">
<button disabled class="login-button-disabled router-link" id="login">
{{ $t('header.login') }}
</button>
</el-tooltip>
<a v-else-if="isShowLoginButton && oauth2Available" v-bind:href="'/api/oauth2/connect?redirect='+ encodeURIComponent(getCurrentPath())" class="login-button router-link" id="login">
{{ $t('header.login') }}
</a>
<span v-show="isShowLogOffButton" class="login-user">{{ loginUsername }}</span>
@ -280,6 +327,19 @@ a.logoff-button {
cursor: pointer;
}
button.login-button-disabled {
margin: 5px;
padding: 9px;
color: #999999;
background-color: #e0e0e0;
border: 1px solid #cccccc;
border-radius: 8px;
cursor: not-allowed;
font-family: 'Roboto';
font-size: 14px;
opacity: 0.6;
}
.login-button:hover,
.logoff-button:hover {
color: #39455f;