Confidential Clients (Backends)
Use this guide if your application has a secure backend (e.g., written in Python, Node.js, etc.) that can safely store a client_secret. This approach is for traditional server-side web applications or services that manage their own user sessions and do not have a built-in OIDC client library. You will need to implement two endpoints in your backend to handle the OIDC flow.
Integration Steps
The process involves your backend initiating the login, handling the redirect from Visual Passcodes, and securely exchanging the authorization code for tokens on the server-side.
- Initiate Login: From a backend endpoint (e.g.,
/login), construct the full Visual Passcodes authorization URL and redirect the user's browser to it. You must generate and store a uniquestatevalue in the user's session to validate the callback later. - Create a Callback Endpoint: Create an endpoint in your application that matches one of your registered
redirect_urivalues (e.g.,/auth/callback). Visual Passcodes redirects the user here after they authenticate. - Handle the Callback:
- The request from Visual Passcodes contains an
authorization_codeand thestateas query parameters. - Crucially, you must verify that the received
statematches the value you stored in the user's session to prevent CSRF attacks.
- The request from Visual Passcodes contains an
- Exchange Code for Tokens:
- With the validated
authorization_code, your backend makes a direct, server-to-server POST request to the Visual Passcodes Token Endpoint. - The request body must include
grant_type=authorization_code, thecode, yourclient_id, and yourclient_secret.
- With the validated
- Process Tokens and Establish Session:
- Visual Passcodes responds with an
id_token,access_token, andrefresh_token. - Your backend must validate the
id_token's signature and claims (iss,aud,exp,nonce) to ensure its authenticity. - Use the information from the validated
id_tokento identify the user and create a local session in your application.
- Visual Passcodes responds with an
Code Examples
- FastAPI (Python)
- Nest.js (TypeScript)
1. Prerequisites:
Before you begin, ensure you have the following:
- Python 3.9+ installed.
- FastAPI and Uvicorn (ASGI server) installed:
pip install fastapi "uvicorn[standard]" httpxfor making HTTP requests in Python:pip install httpxjwcryptofor JWT handling in Python:pip install jwcrypto- Visual Passcodes Client Registration: You must have a confidential client registered with your Visual Passcodes instance. This will provide you with a
client_idand aclient_secret. - Visual Passcodes' Discovery URL: The URL for Visual Passcodes' OpenID Connect discovery endpoint (e.g.,
https://your-server.com/api/oidc/default/.well-known/openid-configuration). - Helper File: The Python helper file (
oidc_helper.pyor similar) containing thegenerate_authorize_request,Oid2Fa,PromptValue, andIdTokenDatafunctions.
2. Project Setup:
Create a project directory and place the provided helper file (let's assume it's named oidc_helper.py) inside it. Create a main.py file for your FastAPI application.
your-fastapi-app/
├── main.py
├── oidc_helper.py
└── settings.py
3. Configuration (settings.py - Example):
For demonstration purposes, let's assume you have a settings.py file or environment variables for your Visual Passcodes configuration.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
VISUAL_AUTH_DISCOVERY_URL: str = "https://your-server.com/api/oidc/default/.well-known/openid-configuration"
VISUAL_AUTH_CLIENT_ID: str = "your_confidential_client_id"
VISUAL_AUTH_CLIENT_SECRET: str = "your_confidential_client_secret"
APP_REDIRECT_URI: str = "http://localhost:8000/auth/callback" # Must match registered redirect_uri in Visual Passcodes
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()
4. FastAPI Application (main.py):
from fastapi import FastAPI, Request, Response, HTTPException, status
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from urllib.parse import urlencode
# Assuming oidc_helper.py is in the same directory
from oidc_helper import generate_authorize_request, Oid2Fa, PromptValue, IdTokenData
from settings import settings # Import your settings
app = FastAPI()
# Add SessionMiddleware to manage user sessions.
# In a real application, use a strong secret key from environment variables.
app.add_middleware(SessionMiddleware, secret_key="super-secret-key-that-should-be-in-env")
# Initialize Oid2Fa client (perform discovery once at startup)
# In a production environment, handle potential network errors during discovery.
try:
oidc_client = Oid2Fa(discover_url=settings.VISUAL_AUTH_DISCOVERY_URL)
except Exception as e:
print(f"Failed to initialize Oid2Fa client: {e}")
# Depending on your application, you might want to exit or log a critical error
# sys.exit(1) # Example for exiting on critical error
@app.get("/")
async def read_root(request: Request):
"""
Root endpoint. Checks if the user is authenticated and displays a welcome message
or a login link.
"""
if "user_info" in request.session:
user = request.session["user_info"]
return f"Welcome, {user['name']} ({user['email']})! <a href='/logout'>Logout</a>"
return "Hello! <a href='/login'>Login with Visual Passcodes</a>"
@app.get("/login")
async def login(request: Request):
"""
Initiates the OIDC authorization code flow.
Generates an authorization request URL and redirects the user to Visual Passcodes.
Stores nonce and code_verifier in the session for later validation.
"""
# Generate authorization request parameters
auth_request = generate_authorize_request(
redirect_uri=settings.APP_REDIRECT_URI,
client_id=settings.VISUAL_AUTH_CLIENT_ID,
scope="openid profile email", # Request standard OIDC scopes
state=secrets.token_urlsafe(16), # Recommended to prevent CSRF
prompt=PromptValue.LOGIN, # Force login prompt
tenant_id="default" # Or your specific tenant_id
)
# Store necessary data in session for callback validation
request.session["oidc_state"] = auth_request.request_params.split("state=")[-1].split("&")[0] # Extract state
request.session["oidc_nonce"] = auth_request.nonce
request.session["oidc_code_verifier"] = auth_request.code_verifier
# Construct the full authorization URL
authorization_url = f"{oidc_client._authorization_uri}?{auth_request.request_params}"
return RedirectResponse(authorization_url)
@app.get("/auth/callback")
async def auth_callback(request: Request, code: str, state: str | None = None):
"""
Handles the callback from Visual Passcodes after user authentication.
Exchanges the authorization code for an ID token and validates it.
"""
# 1. Validate the 'state' parameter to prevent CSRF attacks
expected_state = request.session.pop("oidc_state", None)
if not state or state != expected_state:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid state parameter.")
# Retrieve nonce and code_verifier from session
nonce = request.session.pop("oidc_nonce", None)
code_verifier = request.session.pop("oidc_code_verifier", None)
if not nonce or not code_verifier:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OIDC session data.")
try:
# 2. Exchange the authorization code for an ID token
id_token_data: IdTokenData = oidc_client.get_id_token(
redirect_uri=settings.APP_REDIRECT_URI,
code=code,
client_id=settings.VISUAL_AUTH_CLIENT_ID,
client_secret=settings.VISUAL_AUTH_CLIENT_SECRET, # Sent from server-side
code_verifier=code_verifier,
nonce=nonce # Pass nonce for validation within the helper
)
# Store user info in session
request.session["user_info"] = {
"sub": id_token_data.sub,
"email": id_token_data.email,
"name": id_token_data.name
}
# Redirect to a protected page or home page
return RedirectResponse("/")
except Exception as e:
# Log the error for debugging (e.g., invalid token, network issues)
print(f"Authentication failed: {e}")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication failed.")
@app.get("/logout")
async def logout(request: Request):
"""
Logs out the user by clearing the session.
"""
request.session.clear()
return RedirectResponse("/")
Explanation of the Code:
main.py: This is your FastAPI application.SessionMiddleware: Essential for managing user sessions and storing temporary data likenonceandcode_verifierbetween the authorization request and callback.oidc_client = Oid2Fa(...): TheOid2Faclass from your helper file is initialized at application startup. This performs the OpenID Connect discovery, fetching necessary endpoints and the JWKS (JSON Web Key Set) for token validation./loginendpoint:- Calls
generate_authorize_requestfromoidc_helperto create the parameters for the authorization URL. This function automatically handles PKCE by generating thecode_verifierandcode_challenge. - Stores the
state,nonce, andcode_verifierin the FastAPI session. These are crucial for validating the callback response and ensuring security. - Redirects the user's browser to Visual Passcodes' authorization endpoint.
- Calls
/auth/callbackendpoint:- This is the
redirect_urithat Visual Passcodes will send the user back to after authentication. - It receives the
code(authorization code) andstatefrom Visual Passcodes as query parameters. - State Validation: It first validates the
stateparameter against the one stored in the session to prevent CSRF attacks. - Token Exchange: It retrieves the
nonceandcode_verifierfrom the session and callsoidc_client.get_id_token(). This function makes a POST request to Visual Passcodes' token endpoint, including theclient_id,client_secret,code, andcode_verifier. - ID Token Validation: The
get_id_tokenfunction (withinOid2Fa) handles the validation of the ID token's signature, claims (issuer, audience, expiration), and thenonce. - Session Management: On successful authentication, the user's information (
sub,email,name) from the ID token is stored in the FastAPI session, marking the user as logged in. - Redirects the user to the root (
/) or a protected page.
- This is the
/logoutendpoint: Clears the user's session, effectively logging them out.
This example demonstrates a secure and functional integration of a FastAPI confidential client with the Visual Passcodes service, adhering to OpenID Connect best practices including PKCE and state/nonce validation.
1. Prerequisites:
Before you begin, ensure you have the following:
- Node.js (LTS version recommended) installed.
- Nest CLI installed globally:
npm i -g @nestjs/cli @nestjs/common,@nestjs/core,@nestjs/platform-express(installed with Nest CLI).@nestjs/configfor environment variables:npm install @nestjs/configexpress-sessionand@types/express-sessionfor session management:npm install express-session @types/express-sessionaxiosor@nestjs/axiosfor making HTTP requests:npm install axios(ornpm install @nestjs/axios axios). For simplicity, we'll useaxiosdirectly in the service.josefor JWT handling (signing and verification):npm install jose- Visual Passcodes Client Registration: You must have a confidential client registered with your Visual Passcodes instance. This will provide you with a
client_idand aclient_secret. - Visual Passcodes' Discovery URL: The URL for Visual Passcodes' OpenID Connect discovery endpoint (e.g.,
https://your-server.com/api/oidc/default/.well-known/openid-configuration). - Helper Functions (Adapted): The logic from the Python
oidc_helper.pywill need to be adapted into TypeScript functions or a service.
2. Create a new Nest.js project:
nest new vp-nest-client --strict --package-manager npm
cd vp-nest-client
3. Install dependencies:
npm install @nestjs/config express-session @types/express-session axios jose
4. Configure Environment Variables:
Create a .env file in the root of your project:
VISUAL_AUTH_DISCOVERY_URL=https://your-server.com/api/oidc/{tenant_id}/.well-known/openid-configuration
VISUAL_AUTH_CLIENT_ID=your_client_id
VISUAL_AUTH_CLIENT_SECRET=your_confidential_client_secret
APP_REDIRECT_URI=http://localhost:3000/auth/callback
SESSION_SECRET=a_very_long_and_random_string_for_session_secret
5. Update main.ts for Session Middleware:
Modify src/main.ts to include session middleware.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as session from 'express-session';
import * as process from 'process'; // Import process
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Configure session middleware
app.use(
session({
secret: process.env.SESSION_SECRET || 'default-secret-for-dev', // Use environment variable
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 3600000, // 1 hour
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Use secure cookies in production
},
}),
);
await app.listen(3000);
}
bootstrap();
6. Create a Configuration Module and Service:
Create src/config/config.module.ts and src/config/config.service.ts to load environment variables.
import { Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';
import { ConfigService } from './config.service';
@Module({
imports: [
NestConfigModule.forRoot({
envFilePath: '.env',
isGlobal: true, // Make ConfigService available globally
}),
],
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}
import { Injectable } from '@nestjs/common';
import { ConfigService as NestConfigService } from '@nestjs/config';
@Injectable()
export class ConfigService {
constructor(private nestConfigService: NestConfigService) {}
get visualAuthDiscoveryUrl(): string {
return this.nestConfigService.get<string>('VISUAL_AUTH_DISCOVERY_URL');
}
get visualAuthClientId(): string {
return this.nestConfigService.get<string>('VISUAL_AUTH_CLIENT_ID');
}
get visualAuthClientSecret(): string {
return this.nestConfigService.get<string>('VISUAL_AUTH_CLIENT_SECRET');
}
get appRedirectUri(): string {
return this.nestConfigService.get<string>('APP_REDIRECT_URI');
}
}
7. Create an OIDC Service and Module:
This service will encapsulate the OIDC logic, similar to the Python helper. Create src/oidc/oidc.service.ts.
import { Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common';
import axios from 'axios';
import * as crypto from 'crypto';
import { JWK, jwtVerify, importJWK } from 'jose';
import { ConfigService } from '../config/config.service';
// Define types for OIDC data
interface OIdAuthRequest {
requestParams: string;
nonce: string;
codeVerifier: string;
}
interface IdTokenData {
sub: string;
email: string;
name: string;
}
interface DiscoveryDocument {
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
revocation_endpoint: string;
issuer: string;
id_token_signing_alg_values_supported: string[];
}
@Injectable()
export class OidcService {
private discoveryDocument: DiscoveryDocument;
private jwks: JWK[];
private signingKey: JWK;
constructor(private configService: ConfigService) {
this.initializeOidcClient();
}
private async initializeOidcClient() {
try {
// Fetch discovery document
const discoveryResponse = await axios.get<DiscoveryDocument>(this.configService.visualAuthDiscoveryUrl);
this.discoveryDocument = discoveryResponse.data;
// Fetch JWKS
const jwksResponse = await axios.get<{ keys: JWK[] }>(this.discoveryDocument.jwks_uri);
this.jwks = jwksResponse.data.keys;
// Find the signing key
this.signingKey = this.jwks.find(key => key.use === 'sig');
if (!this.signingKey) {
throw new InternalServerErrorException('No suitable signing key found in JWKS.');
}
console.log('OIDC client initialized successfully.');
} catch (error) {
console.error('Failed to initialize OIDC client:', error.message);
throw new InternalServerErrorException('Failed to initialize OIDC client.');
}
}
private generateCodeVerifier(length: number = 64): string {
if (length < 43 || length > 128) {
throw new Error('Code verifier length must be between 43 and 128 characters.');
}
return crypto.randomBytes(Math.ceil(length * 0.75)).toString('base64url').slice(0, length);
}
private generateCodeChallengeS256(codeVerifier: string): string {
const hash = crypto.createHash('sha256').update(codeVerifier).digest();
return hash.toString('base64url');
}
generateAuthorizeRequest(
scope: string,
state: string,
prompt: 'login' | 'create' | null = null,
loginHint: string | null = null,
emailHint: string | null = null,
tenantId: string = 'default', // Assuming 'default' or '2fa'
): OIdAuthRequest {
if (!this.discoveryDocument) {
throw new InternalServerErrorException('OIDC client not initialized.');
}
const nonce = crypto.randomBytes(16).toString('base64url');
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = this.generateCodeChallengeS256(codeVerifier);
let requestParams = new URLSearchParams({
tenant_id: tenantId,
response_type: 'code',
redirect_uri: this.configService.appRedirectUri,
client_id: this.configService.visualAuthClientId,
scope: scope,
nonce: nonce,
code_challenge_method: 'S256',
code_challenge: codeChallenge,
state: state,
});
if (prompt) requestParams.append('prompt', prompt);
if (loginHint) requestParams.append('login_hint', loginHint);
if (emailHint) requestParams.append('email_hint', emailHint);
return {
requestParams: requestParams.toString(),
nonce: nonce,
codeVerifier: codeVerifier,
};
}
async getTokens(code: string, codeVerifier: string, nonce: string): Promise<IdTokenData> {
if (!this.discoveryDocument) {
throw new InternalServerErrorException('OIDC client not initialized.');
}
const body = new URLSearchParams({
client_id: this.configService.visualAuthClientId,
client_secret: this.configService.visualAuthClientSecret,
code: code,
grant_type: 'authorization_code',
redirect_uri: this.configService.appRedirectUri,
code_verifier: codeVerifier,
tenant_id: 'default', // Or your specific tenant_id
});
try {
const tokenResponse = await axios.post(this.discoveryDocument.token_endpoint, body.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const idToken = tokenResponse.data.id_token;
if (!idToken) {
throw new UnauthorizedException('ID Token not received.');
}
const verifiedJwt = await this.verifyIdToken(idToken, nonce);
// Revoke the ID token after successful verification and use (optional, depending on flow)
// For confidential clients, the ID token itself might not need revocation,
// but access/refresh tokens might. Adjust based on Visual Passcodes' specific revocation endpoint behavior.
// await axios.post(this.discoveryDocument.revocation_endpoint, new URLSearchParams({ token: idToken }).toString(), {
// headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
// });
return {
sub: verifiedJwt.payload.sub as string,
email: verifiedJwt.payload.email as string,
name: verifiedJwt.payload.name as string,
};
} catch (error) {
console.error('Error exchanging code for tokens or verifying ID token:', error.response?.data || error.message);
throw new UnauthorizedException('Authentication failed during token exchange or ID token verification.');
}
}
private async verifyIdToken(idToken: string, nonce: string) {
if (!this.signingKey) {
throw new InternalServerErrorException('Signing key not loaded.');
}
try {
const { payload, protectedHeader } = await jwtVerify(idToken, await importJWK(this.signingKey), {
issuer: this.discoveryDocument.issuer,
audience: this.configService.visualAuthClientId,
algorithms: this.discoveryDocument.id_token_signing_alg_values_supported,
});
if (payload.nonce !== nonce) {
throw new UnauthorizedException('Nonce mismatch during ID token verification.');
}
return { payload, protectedHeader };
} catch (error) {
console.error('ID Token verification failed:', error.message);
throw new UnauthorizedException('ID Token verification failed.');
}
}
}
Create src/oidc/oidc.module.ts to organize the OIDC service.
import { Module } from '@nestjs/common';
import { OidcService } from './oidc.service';
import { ConfigModule } from '../config/config.module';
@Module({
imports: [ConfigModule],
providers: [OidcService],
exports: [OidcService],
})
export class OidcModule {}
8. Create an Authentication Controller:
Create src/auth/auth.controller.ts to handle the web routes.
import { Controller, Get, Req, Res, Query, Session, Redirect, UnauthorizedException } from '@nestjs/common';
import { Request, Response } from 'express';
import { OidcService } from '../oidc/oidc.service';
import { ConfigService } from '../config/config.service';
import * as crypto from 'crypto'; // For generating state
@Controller('auth')
export class AuthController {
constructor(
private oidcService: OidcService,
private configService: ConfigService,
) {}
@Get('login')
@Redirect()
async login(@Session() session: Record<string, any>): Promise<{ url: string }> {
const state = crypto.randomBytes(16).toString('base64url'); // Generate a random state
const authRequest = this.oidcService.generateAuthorizeRequest(
'openid profile email', // Requested scopes
state,
'login', // Force login prompt
null,
null,
'default', // Your tenant ID
);
// Store state, nonce, and codeVerifier in session for callback validation
session.oidcState = state;
session.oidcNonce = authRequest.nonce;
session.oidcCodeVerifier = authRequest.codeVerifier;
// Construct the full authorization URL
const authorizationUrl = `${this.oidcService['discoveryDocument'].authorization_endpoint}?${authRequest.requestParams}`;
return { url: authorizationUrl };
}
@Get('callback')
async callback(
@Req() req: Request,
@Res() res: Response,
@Query('code') code: string,
@Query('state') state: string,
@Session() session: Record<string, any>,
) {
// 1. Validate state parameter
const expectedState = session.oidcState;
if (!state || state !== expectedState) {
throw new UnauthorizedException('Invalid state parameter.');
}
delete session.oidcState; // Clear state from session after validation
// Retrieve nonce and codeVerifier from session
const nonce = session.oidcNonce;
const codeVerifier = session.oidcCodeVerifier;
if (!nonce || !codeVerifier) {
throw new UnauthorizedException('Missing OIDC session data.');
}
delete session.oidcNonce;
delete session.oidcCodeVerifier;
try {
// 2. Exchange authorization code for ID token
const idTokenData = await this.oidcService.getTokens(code, codeVerifier, nonce);
// Store user info in session
session.userInfo = {
sub: idTokenData.sub,
email: idTokenData.email,
name: idTokenData.name,
};
// Redirect to a protected page or home page
res.redirect('/');
} catch (error) {
console.error('Authentication callback failed:', error.message);
throw new UnauthorizedException('Authentication failed.');
}
}
@Get('logout')
@Redirect('/')
logout(@Session() session: Record<string, any>) {
// Clear user info from session
session.destroy((err) => {
if (err) {
console.error('Failed to destroy session:', err);
}
});
return { url: '/' }; // Redirect to home page
}
}
9. Update app.module.ts:
Import the ConfigModule, OidcModule, and AuthController.
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';
import { OidcModule } from './oidc/oidc.module';
import { AuthController } from './auth/auth.controller';
@Module({
imports: [ConfigModule, OidcModule],
controllers: [AppController, AuthController], // Add AuthController here
providers: [AppService],
})
export class AppModule {}
10. Basic AppController (Optional, for testing):
You can keep a simple AppController to test the login flow.
import { Controller, Get, Req, Session } from '@nestjs/common';
import { Request } from 'express';
@Controller()
export class AppController {
@Get()
getHello(@Req() req: Request, @Session() session: Record<string, any>): string {
if (session.userInfo) {
return `Welcome, ${session.userInfo.name} (${session.userInfo.email})! <a href="/auth/logout">Logout</a>`;
}
return 'Hello! <a href="/auth/login">Login with Visual Passcodes</a>';
}
}
11. Run the Application:
npm run start:dev
Access http://localhost:3000 in your browser. Click "Login with Visual Passcodes" to initiate the flow. After successful authentication with Visual Passcodes, you will be redirected back to your Nest.js application.
This example demonstrates how to integrate a confidential client with Visual Passcodes using Nest.js, handling the OIDC Authorization Code Flow, session management, and token validation.