Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,47 @@ All log messages include the service/function name that generated the log for ea
When no user identifier can be found in the JWT, the system will log all available JWT fields to help with debugging. The system prioritizes human-readable identifiers like email addresses and display names over system identifiers like UUIDs.

Make sure that the `APP_CALLBACK_URL` is registered with the OAuth2/OIDC provider, so that it will properly redirect. Without this the Portal will not work.

## Keycloak Front-Channel Logout Configuration

When using Keycloak as your Identity Provider (IdP), the system automatically implements **Option 2: Front-Channel Logout (Browser-based)** for proper session management.

### Configuration Requirements

For Keycloak logout to work properly, ensure your OAuth client configuration includes:

```javascript
{
clientId: '[SET]',
clientSecret: '[SET]',
callbackUrl: 'http://localhost:5174/login/obp/callback',
configUrl: 'http://localhost:7787/realms/master/.well-known/openid-configuration'
}
```

**Note**: The system supports multiple providers simultaneously:
- **OBP-OIDC**: `http://localhost:9000/obp-oidc/.well-known/openid-configuration`
- **Keycloak**: `http://localhost:7787/realms/master/.well-known/openid-configuration`

The Keycloak front-channel logout will only be used when the user is authenticated via the Keycloak provider.

### How It Works

1. **User clicks logout** in the application
2. **App redirects browser** to Keycloak's `end_session_endpoint`
3. **Keycloak ends the session** and logs the user out of all Keycloak-managed clients
4. **Keycloak redirects back** to the application's origin URL

### Logout Flow Parameters

The system automatically sends these parameters to Keycloak's logout endpoint:

- `id_token_hint`: The ID token from the user's session (required for proper logout)
- `post_logout_redirect_uri`: The application origin URL (where to redirect after logout)

### Provider-Specific Behavior

- **Keycloak**: Uses front-channel logout via `end_session_endpoint` with ID token hint
- **Other providers**: Falls back to standard token revocation via `revocation_endpoint`

The system automatically detects the provider type and uses the appropriate logout method. No additional configuration is needed beyond ensuring your Keycloak realm has the proper `end_session_endpoint` configured in its OIDC discovery document.
129 changes: 129 additions & 0 deletions src/lib/oauth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ export class OAuth2ClientWithConfig extends OAuth2Client {
return {
accessToken: () => tokens.access_token,
refreshToken: () => tokens.refresh_token,
idToken: () => {
if ("id_token" in tokens && typeof tokens.id_token === "string") {
return tokens.id_token;
}
throw new Error("Missing or invalid field 'id_token'");
},
accessTokenExpiresAt: () =>
tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null
};
Expand Down Expand Up @@ -219,6 +225,12 @@ export class OAuth2ClientWithConfig extends OAuth2Client {
return {
accessToken: () => retryTokens.access_token,
refreshToken: () => retryTokens.refresh_token,
idToken: () => {
if ("id_token" in retryTokens && typeof retryTokens.id_token === "string") {
return retryTokens.id_token;
}
throw new Error("Missing or invalid field 'id_token'");
},
accessTokenExpiresAt: () =>
retryTokens.expires_in ? new Date(Date.now() + retryTokens.expires_in * 1000) : null
};
Expand All @@ -233,6 +245,123 @@ export class OAuth2ClientWithConfig extends OAuth2Client {
return {
accessToken: () => tokens.access_token,
refreshToken: () => tokens.refresh_token,
idToken: () => {
if ("id_token" in tokens && typeof tokens.id_token === "string") {
return tokens.id_token;
}
throw new Error("Missing or invalid field 'id_token'");
},
accessTokenExpiresAt: () =>
tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null
};
}

async refreshAccessToken(
tokenEndpoint: string,
refreshToken: string,
scopes: string[]
): Promise<any> {
logger.debug('Refreshing access token...');

const body = new URLSearchParams();
body.set('grant_type', 'refresh_token');
body.set('refresh_token', refreshToken);

if (scopes && scopes.length > 0) {
body.set('scope', scopes.join(' '));
}

// Prepare headers
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
};

// Use HTTP Basic Authentication for client credentials
if (this.storedClientSecret) {
const credentials = Buffer.from(`${this.storedClientId}:${this.storedClientSecret}`).toString(
'base64'
);
headers['Authorization'] = `Basic ${credentials}`;
logger.debug('Using Basic Authentication for refresh token request');
} else {
// Public client - include client_id in body
body.set('client_id', this.storedClientId);
logger.debug('Using client_id in request body for refresh token request');
}

logger.debug(`Refresh token request body: ${body.toString()}`);

const response = await fetch(tokenEndpoint, {
method: 'POST',
headers,
body: body.toString()
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
logger.error(`Token refresh error - Status: ${response.status}, Data:`, errorData);

// If Basic Auth failed and we have a client secret, try with credentials in body as fallback
if (response.status === 401 && this.storedClientSecret && !body.has('client_id')) {
logger.warn('Basic Auth failed for refresh, retrying with credentials in request body');

// Add client credentials to body for retry
body.set('client_id', this.storedClientId);
body.set('client_secret', this.storedClientSecret);

// Remove Authorization header
delete headers['Authorization'];

const retryResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers,
body: body.toString()
});

if (!retryResponse.ok) {
const retryErrorData = await retryResponse.json().catch(() => ({}));
logger.error(
`Token refresh retry error - Status: ${retryResponse.status}, Data:`,
retryErrorData
);
throw new Error(
`Token refresh failed after retry: ${retryResponse.status} ${retryResponse.statusText}`
);
}

const retryTokens = await retryResponse.json();
logger.debug('Token refresh response received successfully after retry');

return {
accessToken: () => retryTokens.access_token,
refreshToken: () => retryTokens.refresh_token,
idToken: () => {
if ("id_token" in retryTokens && typeof retryTokens.id_token === "string") {
return retryTokens.id_token;
}
throw new Error("Missing or invalid field 'id_token'");
},
accessTokenExpiresAt: () =>
retryTokens.expires_in ? new Date(Date.now() + retryTokens.expires_in * 1000) : null
};
}

throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`);
}

const tokens = await response.json();
logger.debug('Token refresh response received successfully');

return {
accessToken: () => tokens.access_token,
refreshToken: () => tokens.refresh_token,
idToken: () => {
if ("id_token" in tokens && typeof tokens.id_token === "string") {
return tokens.id_token;
}
throw new Error("Missing or invalid field 'id_token'");
},
accessTokenExpiresAt: () =>
tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null
};
Expand Down
22 changes: 15 additions & 7 deletions src/lib/oauth/sessionHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ const logger = createLogger('OAuthSessionHelper');
import { oauth2ProviderFactory } from './providerFactory';
import type { OAuth2ClientWithConfig } from './client';
import type { Session } from 'svelte-kit-sessions';
import type { SessionOAuthStorageData } from './types';



export interface SessionOAuthData {
client: OAuth2ClientWithConfig;
provider: string;
accessToken: string;
refreshToken?: string;
idToken?: string;
}

export class SessionOAuthHelper {
Expand All @@ -18,7 +22,7 @@ export class SessionOAuthHelper {
* @returns SessionOAuthData if valid, null if invalid/missing
*/
static getSessionOAuth(session: Session): SessionOAuthData | null {
const oauthData = session.data.oauth;
const oauthData = session.data.oauth as SessionOAuthStorageData;
if (!oauthData?.provider || !oauthData?.access_token) {
return null;
}
Expand All @@ -32,16 +36,18 @@ export class SessionOAuthHelper {
client,
provider: oauthData.provider,
accessToken: oauthData.access_token,
refreshToken: oauthData.refresh_token
refreshToken: oauthData.refresh_token,
idToken: oauthData.id_token
};
}

static async updateTokensInSession(
session: Session,
accessToken: string,
refreshToken?: string
refreshToken?: string,
idToken?: string
): Promise<void> {
const currentOauth = session.data.oauth;
const currentOauth = session.data.oauth as SessionOAuthStorageData;
if (!currentOauth) {
throw new Error('No OAuth data in session to update.');
}
Expand All @@ -51,8 +57,9 @@ export class SessionOAuthHelper {
oauth: {
...currentOauth,
access_token: accessToken,
refresh_token: refreshToken || currentOauth.refresh_token // Keep existing refresh token if not provided
}
refresh_token: refreshToken || currentOauth.refresh_token, // Keep existing refresh token if not provided
id_token: idToken || currentOauth.id_token // Keep existing ID token if not provided
} as SessionOAuthStorageData
});

await session.save();
Expand Down Expand Up @@ -82,7 +89,8 @@ export class SessionOAuthHelper {
await this.updateTokensInSession(
session,
tokens.accessToken(),
tokens.refreshToken() || refreshToken
tokens.refreshToken() || refreshToken,
tokens.idToken()
);

logger.info(`Access token refreshed successfully for provider: ${provider}`);
Expand Down
7 changes: 7 additions & 0 deletions src/lib/oauth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,11 @@ export interface OAuth2AccessTokenPayload {
scope?: string;
client_id?: string;
[key: string]: any; // Allow additional properties
}

export interface SessionOAuthStorageData {
access_token: string;
refresh_token?: string;
id_token?: string;
provider: string;
}
17 changes: 13 additions & 4 deletions src/routes/login/[provider]/callback/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { OAuth2Tokens } from 'arctic';
import type { RequestEvent } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
import type { SessionOAuthStorageData } from '$lib/oauth/types';

export async function GET(event: RequestEvent): Promise<Response> {
const { provider: urlProvider } = event.params;
Expand Down Expand Up @@ -172,6 +173,13 @@ export async function GET(event: RequestEvent): Promise<Response> {
});

const obpAccessToken = tokens.accessToken();
let idToken;
try {
idToken = tokens.idToken();
} catch (error) {
// ID token might not be available for all providers/flows
idToken = undefined;
}

logger.debug(`PUBLIC_OBP_BASE_URL from env: ${env.PUBLIC_OBP_BASE_URL}`);
const currentUserUrl = `${env.PUBLIC_OBP_BASE_URL}/obp/v5.1.0/users/current`;
Expand Down Expand Up @@ -206,16 +214,17 @@ export async function GET(event: RequestEvent): Promise<Response> {
);
logger.debug('Full current user data:', user);

if (user.user_id && user.email) {
if (user.user_id && (user.email || user.username)) {
// Store user data in session
const { session } = event.locals;
await session.setData({
user: user,
oauth: {
access_token: obpAccessToken,
refresh_token: tokens.refreshToken(),
...(idToken && { id_token: idToken }),
provider: provider
}
} as SessionOAuthStorageData
});
await session.save();
logger.debug('Session data set:', session.data);
Expand All @@ -226,7 +235,7 @@ export async function GET(event: RequestEvent): Promise<Response> {
}
});
} else {
logger.error('Invalid user data received from OBP - missing user_id or email:', user);
logger.error('Invalid user data received from OBP - missing user_id or email/username:', user);

// Clean up the state cookie
event.cookies.delete('obp_oauth_state', {
Expand All @@ -236,7 +245,7 @@ export async function GET(event: RequestEvent): Promise<Response> {
return new Response(null, {
status: 302,
headers: {
Location: `/login?error=${encodeURIComponent('Invalid user data received. Please contact your administrator.')}`
Location: `/login?error=${encodeURIComponent('Invalid user data received - missing required user identification. Please contact your administrator.')}`
}
});
}
Expand Down
Loading