For: Developers building applications that sign users in with their Audit Scorecard account Provider: Audit Scorecard (OpenID Connect identity provider) Document version: 1.0
Audit Scorecard is the identity provider and the single source of truth for user accounts. Your application is the relying party.
A user signs in with their existing Audit Scorecard account and arrives in your application already authenticated. Your application never sees, requests, or stores their password.
The integration uses standard OpenID Connect (OAuth 2.0 authorization code grant with PKCE). Any conformant OIDC client library will work — you do not need custom crypto or custom endpoints.
| Concern | Owner |
|---|---|
| Password storage and authentication | Audit Scorecard |
Identity verification (id_token signature) |
Audit Scorecard signs; you verify |
| Session management in your app | You |
| Application authorization (who can do what) | You |
| Access scoping to Audit Scorecard data | You (see the separate Integration API) |
Note SSO handles identity only. Audit Scorecard verifies who the user is; your application decides what they are permitted to do. To read data from Audit Scorecard (projects, audits, teams), that is a separate mechanism — the Integration API, documented separately.
Before you begin you need from your Audit Scorecard administrator:
| Item | Description |
|---|---|
| Issuer URL | The base URL of the Audit Scorecard instance |
| Client ID | Issued when your application is registered |
| Client secret | Confidential clients only. Displayed once — store it securely |
| Redirect URI(s) | Exact callback URL(s) on your application |
| Approved scopes | Which of openid, profile, email, roles you may request |
| Type | Use for | Secret | PKCE |
|---|---|---|---|
| Confidential | Server-side apps (Node, Laravel, Django…) | Yes | Recommended |
| Public | Single-page apps, mobile, desktop | No | Mandatory |
Confirm the provider is reachable before writing code:
curl https://YOUR_ISSUER/.well-known/openid-configuration
If this returns a JSON document, the provider is live and you can proceed.
All endpoints are derived from the issuer URL. Most OIDC libraries fetch this automatically via discovery — you normally only need the discovery URL.
| Purpose | Endpoint |
|---|---|
| Discovery | {ISSUER}/.well-known/openid-configuration |
| Authorization | {ISSUER}/oauth/authorize |
| Token | {ISSUER}/oauth/token |
| Userinfo | {ISSUER}/oauth/userinfo |
| JWKS | {ISSUER}/oauth/jwks |
As advertised in the discovery document:
| Property | Value |
|---|---|
response_types_supported |
code |
grant_types_supported |
authorization_code, refresh_token |
subject_types_supported |
public |
id_token_signing_alg_values_supported |
RS256 |
token_endpoint_auth_methods_supported |
client_secret_basic, client_secret_post, none |
code_challenge_methods_supported |
S256, plain |
scopes_supported |
openid, profile, email, roles |
Use S256 for PKCE. Although plain is advertised, it should not be used.
###4.1 Happy path
Provide your administrator with:
The redirect URI must match exactly: scheme, host, port, path, and trailing slash. A mismatch is the single most common cause of a failed redirect.
Example: https://partner-tool.example.com/auth/callback
Not: https://partner-tool.example.com/auth/callback/
| Stack | Library |
|---|---|
| Node.js | openid-client |
| Laravel / PHP | league/oauth2-client |
| Python | authlib or requests-oidc |
| Java / Spring | spring-boot-starter-oauth2-client |
| .NET | IdentityModel / Duende |
| React SPA | oidc-client-ts |
Only openid-client (Node) and league/oauth2-client (PHP) are shown in this guide.
When the user clicks your sign-in button:
code_verifier (43–128 characters).code_challenge = BASE64URL(SHA256(code_verifier)).state and nonce.code_verifier, state, and nonce in the user's session.GET {ISSUER}/oauth/authorize?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://partner-tool.example.com/auth/callback&
scope=openid profile email roles&
state=RANDOM_STATE&
code_challenge=BASE64URL_SHA256_OF_VERIFIER&
code_challenge_method=S256&
nonce=RANDOM_NONCE
state and nonce protect against CSRF and token replay. Both must be verified on return.
state matches the value stored in session. Reject immediately if not.id_token or fetch claims from userinfo.sub.curl -X POST {ISSUER}/oauth/token \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=authorization_code \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d redirect_uri=https://partner-tool.example.com/auth/callback \
-d code=AUTHORIZATION_CODE \
-d code_verifier=ORIGINAL_PKCE_VERIFIER
Public clients omit client_secret.
Verify the id_token locally against the JWKS, or call userinfo with the access token.
{
"iss": "https://YOUR_ISSUER",
"sub": "42",
"aud": "YOUR_CLIENT_ID",
"iat": 1750000000,
"exp": 1750003600,
"auth_time": 1750000000,
"nonce": "RANDOM_NONCE",
"name": "Grace Hopper",
"given_name": "Grace",
"family_name": "Hopper",
"email": "grace@example.com",
"email_verified": true,
"roles": ["Team Manager", "Auditor"]
}
curl {ISSUER}/oauth/userinfo \
-H 'Authorization: Bearer ACCESS_TOKEN' \
-H 'Accept: application/json'
curl -X POST {ISSUER}/oauth/token \
-H 'Accept: application/json' \
-d grant_type=refresh_token \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d refresh_token=REFRESH_TOKEN
Refresh early — proactively renew rather than waiting for a 401.
Request only the scopes you need. Each scope is approved individually.
| Scope | Claims returned | Purpose |
|---|---|---|
openid |
sub |
Required. Stable user identifier |
profile |
name, given_name, family_name, picture |
Display name and avatar |
email |
email, email_verified |
Email address |
roles |
roles |
Active Audit Scorecard role names |
| Claim | Description |
|---|---|
iss |
Issuer — must equal {ISSUER} |
sub |
Subject — the Audit Scorecard user ID, as a string |
aud |
Audience — your client ID |
iat |
Issued-at (Unix timestamp) |
exp |
Expiry (Unix timestamp) |
auth_time |
When the user authenticated |
nonce |
Echoes your nonce — present only when PKCE is used |
Use
subas your primary key.subis stable for the lifetime of the account. Email addresses can change; never key your local user table on email alone.
roles claimroles contains the user's active Audit Scorecard role names as an array of strings.
It reflects identity only — it is not a permission grant to your system.
Use it to map Audit Scorecard roles onto your own authorization model. Your application remains responsible for enforcing access.
| Token | Format | Lifetime | Purpose |
|---|---|---|---|
| Authorization code | Opaque | Very short | Single-use credential returned to your redirect URI |
| Access token | JWT | Short (~1 hour) | Bearer credential for /oauth/userinfo |
| ID token | JWT (RS256) | Matches access token | Signed identity claims |
Cache the JWKS. Do not fetch it on every request. Refresh only when you encounter an
unknown kid (key rotation).
Standard OIDC libraries perform all of these checks automatically. Do not hand-roll them.
npm i openid-client express-session
import express from 'express';
import session from 'express-session';
import { Issuer, generators } from 'openid-client';
const app = express();
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: true }));
// Discover once at startup
const issuer = await Issuer.discover(
`${process.env.AUDIT_SSO_HOST}/.well-known/openid-configuration`
);
const client = new issuer.Client({
client_id: process.env.AUDIT_SSO_CLIENT_ID,
client_secret: process.env.AUDIT_SSO_CLIENT_SECRET,
redirect_uris: ['https://partner-tool.example.com/auth/callback'],
response_types: ['code'],
});
// --- GET /login : send the user to Audit Scorecard ---
app.get('/login', (req, res) => {
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const nonce = generators.nonce();
const state = generators.state();
req.session.oidc = { code_verifier, nonce, state };
res.redirect(client.authorizationUrl({
scope: 'openid profile email roles',
code_challenge,
code_challenge_method: 'S256',
state,
nonce,
}));
});
// --- GET /auth/callback : exchange code and read the user ---
app.get('/auth/callback', async (req, res) => {
try {
const params = client.callbackParams(req);
// Validates state, PKCE, nonce, signature, iss, aud, exp
const tokenSet = await client.callback(
'https://partner-tool.example.com/auth/callback',
params,
{
code_verifier: req.session.oidc.code_verifier,
state: req.session.oidc.state,
nonce: req.session.oidc.nonce,
}
);
const claims = tokenSet.claims(); // { sub, name, email, roles, ... }
// find-or-create your local user keyed by `sub`
req.session.user = {
sub: claims.sub,
name: claims.name,
email: claims.email,
roles: claims.roles ?? [],
};
delete req.session.oidc;
res.redirect('/');
} catch (err) {
console.error('OIDC callback failed:', err);
res.redirect('/login?error=sso_failed');
}
});
app.listen(3000);
If you prefer the claims endpoint over local verification:
const userinfo = await client.userinfo(tokenSet.access_token);
// { sub, name, given_name, family_name, email, email_verified, roles }
composer require league/oauth2-client
.envAUDIT_SSO_HOST=https://YOUR_ISSUER
AUDIT_SSO_CLIENT_ID=your-client-id
AUDIT_SSO_CLIENT_SECRET=your-client-secret
AUDIT_SSO_REDIRECT=https://partner-tool.example.com/auth/callback
config/services.php'audit_sso' => [
'host' => env('AUDIT_SSO_HOST'),
'client_id' => env('AUDIT_SSO_CLIENT_ID'),
'client_secret' => env('AUDIT_SSO_CLIENT_SECRET'),
'redirect' => env('AUDIT_SSO_REDIRECT'),
],
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use League\OAuth2\Client\Provider\GenericProvider;
class SsoController extends Controller
{
private function provider(): GenericProvider
{
$host = config('services.audit_sso.host');
return new GenericProvider([
'clientId' => config('services.audit_sso.client_id'),
'clientSecret' => config('services.audit_sso.client_secret'),
'redirectUri' => config('services.audit_sso.redirect'),
'urlAuthorize' => $host.'/oauth/authorize',
'urlAccessToken' => $host.'/oauth/token',
'urlResourceOwnerDetails' => $host.'/oauth/userinfo',
'scopes' => ['openid', 'profile', 'email', 'roles'],
'scopeSeparator' => ' ',
'pkceMethod' => GenericProvider::PKCE_METHOD_S256,
]);
}
/** GET /login — redirect the user to Audit Scorecard */
public function redirect()
{
$provider = $this->provider();
$url = $provider->getAuthorizationUrl();
session([
'sso_state' => $provider->getState(),
'sso_pkce' => $provider->getPkceCode(),
'sso_nonce' => $provider->getNonce(),
]);
return redirect($url);
}
/** GET /auth/callback — handle the response */
public function callback(Request $request)
{
//1. Reject CSRF attempts
abort_if(
$request->input('state') !== session('sso_state'),
403,
'Invalid state'
);
// 2. Exchange the code
$provider = $this->provider();
$provider->setPkceCode(session('sso_pkce'));
try {
$token = $provider->getAccessToken('authorization_code', [
'code' => $request->input('code'),
]);
} catch (\Throwable $e) {
report($e);
return redirect('/login?error=sso_failed');
}
// 3. Read the verified claims
$claims = $provider->getResourceOwner($token)->toArray();
// $claims['sub'], $claims['email'], $claims['name'], $claims['roles']
// 4. Find-or-create your local user, then authenticate
$user = User::firstOrCreate(
['sso_sub' => $claims['sub']],
['name' => $claims['name'] ?? null, 'email' => $claims['email'] ?? null]
);
Auth::login($user, remember: true);
$request->session()->regenerate();
return redirect()->intended('/dashboard');
}
}
Verify claims.
league/oauth2-clientdoes not validate theid_tokenJWT signature by default when reading claims from userinfo. Add a resource-server (e.g.firebase/php-jwt) or callgetParsedResponse()with aJwtDecoderand verify against the JWKS. Treat the access token as a server-to-server credential over TLS and never trust unverified ID token contents.
Your application's registration may be linked to an Audit Scorecard application record. When it is, the following rules are enforced live — not only at first sign-in.
| Control | Effect |
|---|---|
| Application status | A suspended or expired application stops all sign-ins and token use immediately |
| Allowed scopes | Narrowing the permitted scopes takes effect on tokens that already exist |
| Sign-in allow-list | Users or roles not on the list are refused, including mid-session refresh |
| Account status | A deactivated user cannot sign in or refresh |
A refresh or token exchange can fail after working normally. This is intentional.
On any token failure, discard your stored tokens and re-run sign-in. Do not retry a refresh token that the provider has refused.
Your redirect_uri receives an error query parameter:
| Parameter | Meaning | What to do |
|---|---|---|
error=access_denied |
The user or application is not permitted to sign in | Show a clear message; do not loop |
error=invalid_scope |
A requested scope is not approved | Remove the scope and retry only if appropriate |
error=login_required |
No active session at the provider | Re-run sign-in |
error=consent_required |
Consent is required | Re-run sign-in with consent |
error=invalid_request |
Malformed request | Check client_id, redirect_uri, response_type |
error=server_error |
Provider-side fault | Retry with backoff; contact support if persistent |
The original state is preserved on error redirects. Always validate it.
| Status | Error | Meaning | What to do |
|---|---|---|---|
400 |
invalid_grant |
Code or refresh token refused — user or application no longer authorized | Discard tokens, re-run sign-in |
400 |
invalid_scope |
A requested scope is no longer approved | Contact your administrator |
401 |
invalid_client |
Client ID or secret is wrong | Verify credentials |
400 |
invalid_request |
Missing or malformed parameter | Check the request |
| Status | Error | Meaning | What to do |
|---|---|---|---|
401 |
invalid_token |
Bearer token invalid, expired, or the user is no longer authorized | Discard tokens, re-run sign-in |
403 |
insufficient_scope |
Token lacks the openid scope |
Request openid during sign-in |
Before going to production, confirm each item.
| # | Check | Why |
|---|---|---|
| 1 | redirect_uri is registered and matched exactly |
Prevents open redirect and code theft |
| 2 | state generated, stored in session, and verified on return |
CSRF protection |
| 3 | nonce generated, stored, and verified in the ID token |
Replay protection |
| 4 | PKCE with code_challenge_method=S256 on every flow |
Code interception protection |
| 5 | id_token signature verified against the JWKS |
Prevents forged identity |
| 6 | iss, aud, and exp all validated |
Prevents replay and cross-client attacks |
| 7 | JWKS cached; refetched only on unknown kid |
Correct handling of key rotation |
| 8 | Client secret stored in a secret manager, never in source control | Credential exposure |
| 9 | Local user keyed by sub, not email |
Survives email changes |
| 10 | Session ID regenerated after authentication | Prevents session fixation |
| 11 | Tokens never written to logs | Prevents credential leakage |
| 12 | HTTPS on every endpoint | Baseline transport security |
Q. Do Audit Scorecard permissions transfer to my application?
Only role names are shared, via the roles scope. Audit Scorecard verifies identity; your
application owns its own authorization. Map the role names onto your own permission model.
Q. Is PKCE required?
Mandatory for public clients and strongly recommended for all clients. Send
code_challenge and code_challenge_method=S256. The nonce is only echoed into the id_token
when PKCE is used, so you should always use it.
Q. Is there single logout?
Not yet. Your application manages its own session. Logging out of Audit Scorecard ends only the
local session there; connected tools remain signed in until their tokens expire. Use
refresh_token, or send the user back through the sign-in flow. Plan for a sign-out redirect
in your application instead of relying on provider-side logout.
Q. The redirect returns an error. What should I check?
In order: (1) redirect_uri matches a registered value exactly, including trailing slash;
(2) client_id is correct; (3) scope includes openid; (4) state matches your session.
Q. Why is id_token missing from the token response?
The openid scope was not granted. Request it explicitly — it is not implied.
Q. Why is nonce missing from my ID token?
nonce is only echoed when PKCE is used. Add code_challenge /
code_challenge_method=S256 to your authorization request.
Q. How long are tokens valid?
Access and ID tokens are short-lived (approximately one hour). Refresh tokens are long-lived. Refresh proactively rather than waiting for a 401.
Q. Can I sign in users from my own login page?
Yes, through the Integration API and MCP server, which use bearer tokens rather than a user session. This guide covers interactive sign-in only.
Q. How do I get user data such as projects or audits?
Through the Integration API — a separate REST interface authenticated with a bearer token. It is documented separately and is not part of this SSO flow.
Q. What happens if my application's access is revoked?
The next token exchange or userinfo call fails with invalid_grant or invalid_token. Discard
your stored tokens, clear the local session, and re-run sign-in. See section 10.
Q. Who do I contact for support?
Your Audit Scorecard administrator, for client credentials, scope approvals, and access revocation. For IdP availability, contact your internal support channel.
Q. Is there a sandbox environment?
Contact your Audit Scorecard administrator to request a non-production instance for testing.
| Specification | Relevance |
|---|---|
| OpenID Connect Core 1.0 | id_token, claims, the authentication flow |
| OpenID Connect Discovery 1.0 | The .well-known/openid-configuration document |
| RFC 6749 | OAuth 2.0 authorization framework |
| RFC 7636 | Proof Key for Code Exchange (PKCE) |
| RFC 6750 | Bearer token usage |
| RFC 7519 | JSON Web Token (JWT) |
| RFC 7517 | JSON Web Key Set (JWKS) |
Audit Scorecard is the single source of truth for user identity. This guide covers authentication only; authorization and data access remain your application's responsibility.