Using OIDC in Your Project
This guide walks through setting up the OIDC login flow exactly as the shop template does it — web (cookie strategy) first, then native (header strategy), then local development against the mock identity provider. If you are starting from the shop template, most of this is already in place; use this page to understand and adapt it. If you are starting from the basic template (which has no auth at all), these are the pieces you add.
For the conceptual overview of the flow see OIDC Login Flow.
1. Configuration
Whitelist the protocol
// environment/common.ts
app: {
authentication: {
whitelistedProtocols: ['password', 'oidc']
}
}
Keep 'password' in the list only if you still need the password grant (e.g. during a migration) — its presence is what registers POST auth/login. See Migrating from the password flow.
Token secret and canonical base URL
// environment/common.ts
app: {
canonicalBaseUrl: 'https://my-shop.example.com'
},
server: {
credentials: {
token: {
secret: '{{TOKEN_SECRET|<32-char-fallback-for-local-dev>}}'
}
}
}
server.credentials.token.secretencrypts the JWE session tokens. It must be exactly 32 characters; theAuthModulerefuses to start without it.app.canonicalBaseUrlmatters twice: it is the JWEissclaim, and it seeds the default OIDCredirect_uri(${canonicalBaseUrl}/api/v2/auth/callback). It must be correct per environment, or the IdP will redirect users to the wrong host.
OAuth client credentials (server-side)
The CommerceUserAuthProvider reads its OAuth client from hybris.oauth:
// environment/common.ts
hybris: {
oauth: {
client_id: 'my_client',
client_secret: '{{OAUTH_CLIENT_SECRET|...}}'
// optional: scope (defaults to 'openid'), redirect_uri (defaults to
// `${canonicalBaseUrl}/api/v2/auth/callback`)
}
}
The provider discovers the IdP endpoints from GET {hybris.api}/authorizationserver/oauth/ and falls back to manually configured authorize/token/introspect/revoke URLs when discovery is unavailable.
2. Register the AuthModule (server)
Register the AuthModule first in initModules() — it registers the jwt/cookie/header Hapi auth strategies that other modules' routes reference:
// src/shop/server/module/server.tsx
import { AuthModule } from '@archibald/auth';
import { CommerceStaticAuthProvider, CommerceUserAuthProvider } from '@archibald/commerce/auth';
export class Server extends CoreServer {
public async initModules() {
await this.registerModules([
new AuthModule({
providers: [
new CommerceUserAuthProvider({
config: () => this.configService.get('hybris.api'),
credentials: () => this.configService.get('hybris.oauth')
}),
new CommerceStaticAuthProvider({
config: () => this.configService.get('hybris.api'),
credentials: () => this.configService.get('hybris.oauth')
})
],
options: () => ({
strategy: { type: 'cookie' },
token: { secret: this.configService.get('server.credentials.token.secret') },
refresh: { secret: this.configService.get('server.credentials.token.secret') }
})
})
// ... other modules
]);
}
}
Notes:
optionsmust be a function (dynamic accessor) — a static object throws when multi-country support is on.CommerceUserAuthProviderimplements the OIDC pieces (loginUrl,redirectCallback,token) including PKCE (S256) andstatevalidation, plus the server-side anonymous cart/wishlist merge on successful login. To customize it, extend it — the portal platform does this withCommerceB2BUserAuthProvider extends CommerceUserAuthProvider.
3. Create the SessionClient (web client)
// src/shop/client/api/creators/session.ts
import { SessionClient, CookieAuthAdapter, type OidcConfig } from '@archibald/auth';
import { api } from 'shop/client/api';
const oidcConfig: OidcConfig = {
queryParamsWhitelist: ['returnPath']
};
export default new SessionClient({ authenticateOnServer: true, adapter: CookieAuthAdapter, api, oidcConfig });
oidcConfig.queryParamsWhitelistis the allowlist of query params forwarded fromlogin()toGET auth/login. The template forwardsreturnPathso users come back to the page they started on; add your own params here if your provider needs them.authenticateOnServer: trueletsisLoggedIn()read the SSR session during server rendering, so the logged-in header renders in the initial HTML after the callback redirect.
Register it via the SessionClientProvider in your App component (the template does this through ProviderComposer):
provider(SessionClientProvider, { client: SessionClient }),
4. The login form
With OIDC the storefront no longer renders credential inputs — the IdP does. The template's login form is a single button:
// src/shop/client/features/login/components/form/LoginForm.tsx
function LoginForm() {
const { login, isLoading } = useLogin();
async function handleLogin() {
const params = new URLSearchParams();
params.append('returnPath', window.location.pathname);
await login({
authProtocol: 'oidc',
oidcSearchParams: Object.fromEntries(params.entries())
});
}
return (
<Box aria-label="login form">
<Button appearance="primary" fullWidth onClick={handleLogin} loading={isLoading} disabled={isLoading} type="submit">
<Message code="account.form.button.login" simpleText />
</Button>
</Box>
);
}
login() triggers a full-page navigation to GET /api/v2/auth/login — it resolves with { success: true, redirect: true } and the page unloads. Do not expect a user object or a login event from this call; the session exists on the next SSR render (after GET auth/callback redirected back).
The template's registration flow uses the same trick: after creating the account via the Commerce API it calls login({ authProtocol: 'oidc' }) to bounce the fresh user through the IdP.
5. Native (React Native / Expo)
The app platform shadows the session creator (file shadowing — src/app/... overrides src/shop/... when building platform=app):
// src/app/client/api/creators/session.ts
import { SessionClient, StorageAdapter, type NativeAuthCredentials } from '@archibald/auth';
import { NativeAuthAdapter } from '@archibald/auth';
import { deleteItemAsync, getItemAsync, setItemAsync } from 'expo-secure-store';
class CustomNativeStorageAdapter extends StorageAdapter<string> {
/* set/get/remove wrapping expo-secure-store */
}
api.register(authBeforeMiddleware); // attaches Authorization: Bearer <token>
api.register(authUnauthorizedMiddleware); // refreshes on 401, logs out if refresh fails
export default new SessionClient<User, NativeAuthCredentials>({
authenticateOnServer: false,
adapter: NativeAuthAdapter,
storage: CustomNativeStorageAdapter,
api
});
NativeAuthAdapter is exported only from the @archibald/auth expo entry point (it depends on expo-auth-session).
The app server registers the same providers but with strategy: { type: 'header' } — tokens are returned in the POST auth/token response body instead of cookies, and persisted to the secure store.
The client OIDC parameters live in project config (typed in the template via ProjectAppConfig):
// environment/common.ts (app platform tenant)
app: {
commerce: {
oidc: {
clientId: 'trusted_client',
discoveryEndpoint: 'https://<commerce-host>/authorizationserver/oauth/',
authorizationEndpoint: 'https://<commerce-host>/authorizationserver/oauth/authorize'
}
}
}
And the native login form passes them straight through:
// src/app/client/features/login/components/form/LoginForm.tsx
const oidcConfig = Config.get('app.commerce.oidc');
if (!oidcConfig) throw new Error('OIDC configuration is missing');
await login({ ...oidcConfig, redirectPath: '/', usePKCE: false, authProtocol: 'oidc' });
6. Local development: the mock IdP
With hybris.api.mocked: true the shop template's MockModule serves a complete mock authorization server, so the full OIDC round trip works offline:
| Method | Path | Purpose |
|---|---|---|
| GET | /authorizationserver/oauth/ and .../.well-known/openid-configuration | Discovery document |
| GET/POST | /authorizationserver/oauth/authorize | Hybris-lookalike login page; on success redirects with code=MOCK_CODE_<username> |
| POST | /authorizationserver/oauth/token | Token endpoint (all four grant types) |
| POST | /authorizationserver/oauth/introspect | Token introspection |
| POST | /authorizationserver/oauth/revoke | Token revocation (logout) |
The mock accounts are erika.musterfrau@hybris.com and max.mustermann@hybris.com (password = the email address). Point the native client at the mock in environment/local.ts:
commerce: {
oidc: {
clientId: 'client_id',
redirectPath: '/',
discoveryEndpoint: 'http://{{PUBLIC_IP|null}}:{{SERVER_PORT|3100}}/authorizationserver/oauth',
authorizationEndpoint: 'http://{{PUBLIC_IP|null}}:{{SERVER_PORT|3100}}/authorizationserver/oauth/authorize'
}
}
When the configured SERVER_PORT differs from the port in app.canonicalBaseUrl, CommerceUserAuthProvider rewrites the port in the redirect_uri accordingly — so a dev server that fell back to another port still gets a working callback.
7. Testing the flow (e2e)
The Playwright page object shows the observable steps and the two classic pitfalls:
// src/shop/e2e-playwright/pages/LoginPage.ts
async login(username = getTestUser().email, password = getTestUser().password) {
await waitForHydration(this.page, selectors.loginPage.form.formSelector);
await this.page.locator(selectors.loginPage.form.loginButton).click(); // starts the OIDC redirect
await this.fillLoginFormAndSubmit(username, password); // fills the IdP's page, not yours
const cookies = await this.page.context().cookies();
expect(cookies.find((cookie) => cookie.name === 'nct')).toBeDefined();
expect(cookies.find((cookie) => cookie.name === 'ncr')).toBeDefined();
await waitForHydration(this.page, '#app');
return this;
}
- The login form is a lazily hydrated island — clicking before hydration silently drops the redirect. Wait for hydration first.
- The
username/password/submitselectors belong to the IdP's login page (locally: the mock's HTML), not your storefront. - The callback lands on a fresh SSR page — wait for
#appto hydrate again before further interaction.
Checklist
app.authentication.whitelistedProtocolsincludes'oidc'.server.credentials.token.secretset (32 chars) andapp.canonicalBaseUrlcorrect per environment.AuthModuleregistered first,optionsas a function,strategy: { type: 'cookie' }(web) /{ type: 'header' }(native).hybris.oauthclient credentials configured (plus optionalscope/redirect_uri).SessionClientwithCookieAuthAdapter+oidcConfig.queryParamsWhitelist.- Login form calls
login({ authProtocol: 'oidc', oidcSearchParams: { returnPath } }). - Native:
app.commerce.oidcconfig, shadowed session creator withNativeAuthAdapter+ secure-store adapter + bearer/401 middlewares. - Local dev: mock auth routes active via
hybris.api.mocked: true.