Aller au contenu principal
Version 0.1Brouillon

Platform and framework guides

Overview

LuxID does not publish a first-party SDK. Integration is done with community-maintained OpenID Connect libraries. Every library listed in this guide is mature, actively maintained, has undergone security review by its respective community, and is widely deployed in production. Do not write your own token validation or PKCE implementation - use the libraries.

All examples on this page share the same LuxID endpoints:

EndpointURL
Discoveryhttps://login.luxid.lu/.well-known/openid-configuration
Issuerhttps://login.luxid.lu
Authorisationhttps://login.luxid.lu/mga/sps/oauth/oauth20/authorize
Tokenhttps://login.luxid.lu/mga/sps/oauth/oauth20/token
JWKShttps://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID

UAT mirrors replace login.luxid.lu with login-uat.luxid.lu. All other path segments remain identical.

Obtain your client_id and client_secret from LuxID after completing Configure LuxID. Allowed redirect URIs are registered at the same time.


Spring security (OAuth2 client / OIDC login)

Library: Spring Security OAuth2 Client, built into Spring Security 6+.

Dependency (pom.xml):

pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

Configuration (application.yml):

application.yml
spring:
security:
oauth2:
client:
provider:
luxid:
issuer-uri: https://login.luxid.lu
registration:
luxid:
provider: luxid
client-id: ${LUXID_CLIENT_ID}
client-secret: ${LUXID_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope:
- openid
- profile
- email
redirect-uri: "{baseUrl}/login/oauth2/code/luxid"
client-name: LuxID

Spring Security resolves all provider metadata (authorisation endpoint, token endpoint, JWKS URI) automatically from the issuer-uri discovery document. No manual endpoint configuration is required.

Security configuration class:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults());
return http.build();
}
}

Things to watch out for:

  • PKCE (S256) is enforced automatically in Spring Security 6+. No extra configuration is needed.
  • The default redirect URI template uses {baseUrl} - ensure your registered redirect URI with LuxID matches the resolved value exactly, including scheme and port.
  • In production behind a reverse proxy, set server.forward-headers-strategy: framework so {baseUrl} resolves to https:// rather than http://.
  • Claims from the UserInfo endpoint are not automatically merged into the principal. Configure an OidcUserService bean if you need profile or email claims on the OidcUser.
  • Refresh tokens are issued when the offline_access scope is included. Spring Security handles silent refresh automatically when offline_access is in the registration scopes.

ASP.NET core (OpenIdConnect middleware)

Library: Microsoft.AspNetCore.Authentication.OpenIdConnect, included in the ASP.NET Core shared framework from .NET 6 onwards. No additional NuGet package is needed for most scenarios, though some projects pin the version explicitly.

Optional explicit pin (*.csproj):

*.csproj
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.*" />

Configuration (Program.cs):

Program.cs
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

var builder = WebApplication.CreateBuilder(args);

builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://login.luxid.lu";
options.ClientId = builder.Configuration["LuxID:ClientId"];
options.ClientSecret = builder.Configuration["LuxID:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("profile");
options.Scope.Add("email");
// Uncomment to request refresh tokens:
// options.Scope.Add("offline_access");
});

builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", (HttpContext ctx) =>
ctx.User.Identity!.Name ?? "anonymous").RequireAuthorization();
app.Run();

Things to watch out for:

  • Set GetClaimsFromUserInfoEndpoint = true - without it, the email and profile claims are not populated from the UserInfo response, only from the (slim) ID token.
  • SaveTokens = true persists the access token and refresh token in the authentication cookie so you can retrieve them later via HttpContext.GetTokenAsync("access_token").
  • PKCE is enabled automatically from ASP.NET Core 7 onwards. Do not disable it.
  • options.Authority triggers metadata discovery on first request. The metadata is cached by default; no extra action is needed.
  • Store ClientSecret in environment variables or user secrets - never in appsettings.json checked into source control.
  • For multi-environment deployments, externalise Authority and the redirect URI to environment-specific configuration rather than hardcoding.

Node.js (openid-client)

Library: openid-client (opens in a new tab) by Filip Skokan. Implements OIDC Core, PKCE, and the full token lifecycle.

Install:

npm install openid-client

Issuer discovery and client setup:

import { Issuer, generators } from 'openid-client';

const luxidIssuer = await Issuer.discover('https://login.luxid.lu');

const client = new luxidIssuer.Client({
client_id: process.env.LUXID_CLIENT_ID,
client_secret: process.env.LUXID_CLIENT_SECRET,
redirect_uris: ['https://yourapp.example.com/auth/callback'],
response_types: ['code'],
});

Express route handlers:

import express from 'express';
import session from 'express-session';

const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
}));

// Start login
app.get('/auth/login', (req, res) => {
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
req.session.codeVerifier = codeVerifier;

const authUrl = client.authorizationUrl({
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
res.redirect(authUrl);
});

// Handle callback
app.get('/auth/callback', async (req, res) => {
const params = client.callbackParams(req);
const tokenSet = await client.callback(
'https://yourapp.example.com/auth/callback',
params,
{ code_verifier: req.session.codeVerifier }
);

req.session.claims = tokenSet.claims();
req.session.accessToken = tokenSet.access_token;
res.redirect('/dashboard');
});

Things to watch out for:

  • Always generate a fresh codeVerifier per login attempt and store it in the server-side session, not in a cookie.
  • openid-client validates the ID token signature, issuer, audience, nonce, and iat/exp automatically. Do not bypass this validation.
  • JWKS keys are cached and rotated transparently. No manual key refresh is needed.
  • If you need to call the UserInfo endpoint explicitly, use client.userinfo(accessToken).
  • Dispose of codeVerifier from the session after the callback is processed to prevent replay.

Next.js (auth.js / NextAuth.js)

Library: Auth.js (opens in a new tab) (formerly NextAuth.js). Version 5 is recommended for the Next.js App Router.

Install:

npm install next-auth

Route handler (app/api/auth/[...nextauth]/route.ts):

import NextAuth from 'next-auth';

export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
{
id: 'luxid',
name: 'LuxID',
type: 'oidc',
issuer: 'https://login.luxid.lu',
clientId: process.env.LUXID_CLIENT_ID!,
clientSecret: process.env.LUXID_CLIENT_SECRET!,
authorization: {
params: {
scope: 'openid profile email',
},
},
},
],
callbacks: {
async session({ session, token }) {
// Expose the sub claim on the session if needed:
session.user.id = token.sub ?? '';
return session;
},
},
});

export const { GET, POST } = handlers;

Environment variables (.env.local):

LUXID_CLIENT_ID=your-client-id
LUXID_CLIENT_SECRET=your-client-secret
AUTH_SECRET=a-random-32-char-string

Generate AUTH_SECRET with openssl rand -base64 32.

Things to watch out for:

  • Auth.js automatically performs PKCE and state validation when type: 'oidc' is set.
  • The issuer field triggers .well-known/openid-configuration discovery on startup.
  • Refresh token rotation requires adding offline_access to the scope and implementing a jwt callback that calls the token endpoint when the access token has expired.
  • Do not expose LUXID_CLIENT_SECRET to the browser - it must only exist in server-side environment variables.
  • When deploying to Vercel Edge Runtime, use Auth.js v5, which is Edge-compatible. v4 (NextAuth) requires the Node.js runtime.

React SPA (oidc-client-ts)

Library: oidc-client-ts (opens in a new tab) by authts. Browser-native OIDC client with PKCE support.

Install:

npm install oidc-client-ts

UserManager configuration:

import { UserManager, WebStorageStateStore } from 'oidc-client-ts';

export const userManager = new UserManager({
authority: 'https://login.luxid.lu',
client_id: import.meta.env.VITE_LUXID_CLIENT_ID,
redirect_uri: `${window.location.origin}/auth/callback`,
// LuxID has no OIDC end_session_endpoint (no RP-initiated logout), so post_logout_redirect_uri is unused - clear the local session on sign-out instead.
// post_logout_redirect_uri: `${window.location.origin}/`,
response_type: 'code',
scope: 'openid profile email',
// Store the user object in session storage, not local storage:
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
// Automatic silent token renewal requires a silent_redirect_uri:
silent_redirect_uri: `${window.location.origin}/auth/silent-callback`,
automaticSilentRenew: true,
});

Callback page (/auth/callback):

import { userManager } from './userManager';

// Call this once on the callback route:
await userManager.signinRedirectCallback();
window.location.replace('/dashboard');

Critical security warning: Never store a refresh token in browser local storage or session storage. Local storage is accessible to any JavaScript on the page, including third-party scripts. If your application needs refresh tokens, implement the Backend For Frontend (BFF) pattern: the browser authenticates with your backend, the backend holds the refresh token server-side, and the browser only receives a short-lived access token via a secure, HttpOnly, SameSite=Strict cookie.

Things to watch out for:

  • oidc-client-ts performs PKCE and state validation on the callback automatically.
  • Silent renewal uses a hidden iframe to hit the authorisation endpoint with prompt=none. This requires the user to have an active session at login.luxid.lu and third-party cookie support in the browser - the latter is increasingly restricted by modern browsers. The BFF pattern avoids this dependency.
  • client_secret must NOT be included in browser-side configuration. LuxID supports public clients (no secret) for SPAs - confirm with LuxID that your Application is registered as a public client.
  • Always listen for userManager.events.addUserSessionChanged to detect session termination by the IdP.

Angular (angular-auth-oidc-client)

Library: angular-auth-oidc-client (opens in a new tab) by Damien Bod. Angular-native OIDC module with full PKCE support.

Install:

npm install angular-auth-oidc-client

App configuration (app.config.ts with standalone components):

app.config.ts
import { ApplicationConfig } from '@angular/core';
import {
provideAuth,
withAppInitializerAuthCheck,
} from 'angular-auth-oidc-client';

export const appConfig: ApplicationConfig = {
providers: [
provideAuth({
config: {
authority: 'https://login.luxid.lu',
redirectUrl: window.location.origin + '/auth/callback',
postLogoutRedirectUri: window.location.origin,
clientId: import.meta.env['NG_APP_LUXID_CLIENT_ID'] ?? '',
scope: 'openid profile email',
responseType: 'code',
silentRenew: true,
useRefreshToken: false, // set true only with a BFF pattern
renewTimeBeforeTokenExpiresInSeconds: 30,
},
}),
withAppInitializerAuthCheck(),
],
};

Auth guard example:

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { map } from 'rxjs';

export const authGuard: CanActivateFn = () => {
const oidcService = inject(OidcSecurityService);
const router = inject(Router);

return oidcService.isAuthenticated$.pipe(
map(({ isAuthenticated }) =>
isAuthenticated || router.createUrlTree(['/login'])
)
);
};

Things to watch out for:

  • withAppInitializerAuthCheck() triggers an automatic silent check on app startup. Ensure the callback route is excluded from the auth guard.
  • PKCE is enabled by default. Do not override codeChallengeMethod.
  • As with all SPA clients, never include a client_secret. Register as a public client with LuxID.
  • silentRenew: true with useRefreshToken: false uses the iframe/prompt=none approach, which is affected by third-party cookie restrictions. Consider the BFF pattern for production.
  • To read user claims, subscribe to oidcService.userData$.

React native (react-native-app-auth)

Library: react-native-app-auth (opens in a new tab) by Formidable. Wraps AppAuth-iOS and AppAuth-Android natively.

Install:

npm install react-native-app-auth
npx pod-install # iOS only

iOS - URL scheme (Info.plist):

Info.plist
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.yourcompany.yourapp</string>
</array>
</dict>
</array>

Android - Intent filter (AndroidManifest.xml):

AndroidManifest.xml
<activity android:name="net.openid.appauth.RedirectUriReceiverActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.yourcompany.yourapp" />
</intent-filter>
</activity>

Configuration and login flow:

import { authorize, refresh } from 'react-native-app-auth';

const config = {
issuer: 'https://login.luxid.lu',
clientId: 'YOUR_CLIENT_ID',
redirectUrl: 'com.yourcompany.yourapp:/auth/callback',
scopes: ['openid', 'profile', 'email'],
usePKCE: true,
};

// Authenticate
const authState = await authorize(config);
console.log('Access token:', authState.accessToken);
console.log('ID token:', authState.idToken);

// Refresh (requires offline_access scope and a refresh token)
const refreshedState = await refresh(config, {
refreshToken: authState.refreshToken,
});

Things to watch out for:

  • usePKCE: true is the default and must remain enabled. LuxID does not support the implicit flow.
  • The redirect URI scheme must be registered with LuxID exactly as it appears in redirectUrl (e.g. com.yourcompany.yourapp:/auth/callback).
  • On Android, use App Links (HTTPS redirect URIs) rather than custom schemes for production to prevent redirect hijacking. This requires domain verification via Digital Asset Links.
  • On iOS, use Universal Links for the same reason.
  • Store the refresh token in the platform's secure storage (react-native-keychain or expo-secure-store), never in AsyncStorage.
  • react-native-app-auth does not manage token refresh automatically. Implement a wrapper that calls refresh() when the access token has expired.

iOS native (AppAuth-iOS)

Library: AppAuth-iOS (opens in a new tab) by the OpenID Foundation. Certified OIDC Relying Party implementation.

CocoaPods (Podfile):

pod 'AppAuth', '~> 1.7'

Swift - Discovery and authorisation request:

import AppAuth

let issuer = URL(string: "https://login.luxid.lu")!

// 1. Discover endpoints
OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { config, error in
guard let config else {
print("Discovery failed: \(error!.localizedDescription)")
return
}

// 2. Build authorisation request (PKCE is automatic)
let request = OIDAuthorizationRequest(
configuration: config,
clientId: "YOUR_CLIENT_ID",
clientSecret: nil, // nil for public clients
scopes: [OIDScopeOpenID, OIDScopeProfile, "email"],
redirectURL: URL(string: "com.yourcompany.yourapp:/auth/callback")!,
responseType: OIDResponseTypeCode,
additionalParameters: nil
)

// 3. Perform authorisation
// currentAuthorizationFlow must be stored as a property on AppDelegate
AppDelegate.currentAuthorizationFlow =
OIDAuthState.authState(
byPresenting: request,
presenting: self
) { authState, error in
guard let authState else {
print("Authorisation failed: \(error!.localizedDescription)")
return
}
// Access token: authState.lastTokenResponse?.accessToken
// ID token: authState.lastTokenResponse?.idToken
}
}

URL handling in AppDelegate.swift:

AppDelegate.swift
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if let flow = Self.currentAuthorizationFlow,
flow.resumeExternalUserAgentFlow(with: url) {
Self.currentAuthorizationFlow = nil
return true
}
return false
}

Things to watch out for:

  • PKCE is generated automatically by AppAuth-iOS. Do not bypass it.
  • For iOS 11+, use ASWebAuthenticationSession (the default in AppAuth 1.6+) rather than SFSafariViewController for improved security isolation.
  • Use Universal Links rather than custom URI schemes in production to prevent redirect hijacking - AppAuth supports both.
  • Persist OIDAuthState using NSSecureCoding to the iOS Keychain, not UserDefaults.
  • OIDAuthState.withFreshTokens() handles silent refresh automatically when a refresh token is available.

Android native (AppAuth-android)

Library: AppAuth-Android (opens in a new tab) by the OpenID Foundation.

Gradle dependency (build.gradle):

build.gradle
implementation 'net.openid:appauth:0.11.1'

Endpoint discovery and authorisation:

import net.openid.appauth.*

AuthorizationServiceConfiguration.fetchFromIssuer(
Uri.parse("https://login.luxid.lu")
) { config, ex ->
if (config == null) {
Log.e("LuxID", "Discovery failed", ex)
return@fetchFromIssuer
}

val authRequest = AuthorizationRequest.Builder(
config,
BuildConfig.LUXID_CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse("com.yourcompany.yourapp:/auth/callback")
)
.setScopes("openid", "profile", "email")
.setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier())
.build()

val authService = AuthorizationService(this)
val intent = authService.getAuthorizationRequestIntent(authRequest)
startActivityForResult(intent, RC_AUTH)
}

Token exchange (in onActivityResult):

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == RC_AUTH) {
val resp = AuthorizationResponse.fromIntent(data!!)
val ex = AuthorizationException.fromIntent(data)
val authState = AuthState(resp, ex)

resp?.let {
val authService = AuthorizationService(this)
authService.performTokenRequest(resp.createTokenExchangeRequest()) { tokenResp, tokenEx ->
authState.update(tokenResp, tokenEx)
// tokenResp?.accessToken
// tokenResp?.idToken
}
}
}
}

Redirect intent filter (AndroidManifest.xml):

AndroidManifest.xml
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.yourcompany.yourapp" />
</intent-filter>
</activity>

Things to watch out for:

  • Set android:exported="true" on RedirectUriReceiverActivity for Android 12+ (API 31+) - the manifest compiler enforces this.
  • Use App Links (HTTPS redirect URIs) with Digital Asset Links verification for production to prevent redirect hijacking by a malicious app.
  • Serialise AuthState with AuthState.jsonSerialize() and store in encrypted SharedPreferences or the Android Keystore, not plain SharedPreferences.
  • AuthState.performActionWithFreshTokens() handles transparent refresh token renewal.
  • PKCE (setCodeVerifier(...)) is handled by AppAuth automatically when you use AuthorizationRequest.Builder. Do not omit it.

PHP (jumbojett/openid-connect-php)

Library: jumbojett/openid-connect-php (opens in a new tab). Lightweight, widely deployed OIDC Relying Party for PHP 7.4+.

Install:

composer require jumbojett/openid-connect-php

Minimal integration:

<?php

require 'vendor/autoload.php';

use Jumbojett\OpenIDConnectClient;

session_start();

$oidc = new OpenIDConnectClient(
'https://login.luxid.lu',
getenv('LUXID_CLIENT_ID'),
getenv('LUXID_CLIENT_SECRET')
);

$oidc->setRedirectURL('https://yourapp.example.com/auth/callback');
$oidc->addScope(['openid', 'profile', 'email']);

// Enforce TLS verification - never set to false in production:
$oidc->setVerifyHost(true);
$oidc->setVerifyPeer(true);

// Enable PKCE:
$oidc->setCodeChallengeMethod('S256');

// Trigger login (redirects to LuxID):
$oidc->authenticate();

// After callback, claims are available:
$sub = $oidc->getVerifiedClaims('sub');
$email = $oidc->getVerifiedClaims('email');
$name = $oidc->getVerifiedClaims('name');

echo "Welcome, {$name} ({$email})";

Things to watch out for:

  • setVerifyHost(true) and setVerifyPeer(true) must both be set. Disabling TLS verification voids the security of the entire flow.
  • The library stores the state and nonce in the PHP session. Ensure session_start() is called before authenticate().
  • getVerifiedClaims() returns claims from the ID token only. To fetch UserInfo endpoint claims, call requestUserInfo() separately.
  • Check the library's GitHub for patches; it does not follow a predictable release cadence.
  • PHP sessions must be stored server-side (Redis, database) in load-balanced deployments - file-based sessions break across nodes.

Python (authlib with flask)

Library: Authlib (opens in a new tab). Implements OIDC Core, PKCE, and token validation. Supports Flask and Django.

Install:

pip install authlib flask requests

Flask integration:

import os
from flask import Flask, redirect, url_for, session
from authlib.integrations.flask_client import OAuth

app = Flask(__name__)
app.secret_key = os.environ['FLASK_SECRET_KEY']

oauth = OAuth(app)

oauth.register(
name='luxid',
server_metadata_url=(
'https://login.luxid.lu/.well-known/openid-configuration'
),
client_id=os.environ['LUXID_CLIENT_ID'],
client_secret=os.environ['LUXID_CLIENT_SECRET'],
client_kwargs={
'scope': 'openid profile email',
'code_challenge_method': 'S256', # enable PKCE
},
)


@app.route('/login')
def login():
redirect_uri = url_for('auth_callback', _external=True)
return oauth.luxid.authorize_redirect(redirect_uri)


@app.route('/auth/callback')
def auth_callback():
token = oauth.luxid.authorize_access_token()
user_info = token.get('userinfo')
session['user'] = {
'sub': user_info.get('sub'),
'email': user_info.get('email'),
'name': user_info.get('name'),
}
return redirect('/')


@app.route('/')
def index():
user = session.get('user')
if not user:
return redirect('/login')
return f"Hello {user['name']}"

Django integration (alternative):

Authlib also ships authlib.integrations.django_client. The configuration mirrors the Flask example - replace OAuth(app) with OAuth() and wire it into Django's URL configuration via path('auth/', include('authlib.integrations.django_client.urls')) or manually.

Things to watch out for:

  • server_metadata_url triggers discovery on first request. Authlib caches the metadata with respect to the Cache-Control header from LuxID's discovery endpoint.
  • code_challenge_method: 'S256' enables PKCE. Do not omit it.
  • authorize_access_token() validates the ID token signature, issuer, audience, nonce, and expiry automatically.
  • Use flask-session with a server-side backend (Redis, SQLAlchemy) in production. The default client-side cookie session has a 4 KB size limit, which the token set may exceed.
  • Never log the full token dict - it contains the access token and potentially the refresh token.

Cross-references

Mise à jour le 2026-07-02