Two identity providers: GitHub Actions for signing, Entra ID for the console¶
One deployment, two populations, two identity providers — plus a third for a partner's auditors. The pipeline that signs releases proves its identity through GitHub's workload-identity issuer; the operators who administer the PKI sign in through Microsoft Entra ID; partner auditors arrive through their own IdP and see only their tenant.
These are deliberately not the same provider, and secsy-pki keeps them from
becoming interchangeable.
| File | Purpose |
|---|---|
config.yaml |
Three providers across both axes, each with its own audience and claim → role mapping |
Reference: docs/security/authentication.md#multiple-oidc-providers,
docs/security/rbac-and-audit.md.
The two axes¶
There are two independent OIDC paths, and the common mistake is to assume one block serves both:
| Axis | Config block | Credential | Who |
|---|---|---|---|
| Machine | oidc.providers[] |
Authorization: Bearer <jwt> the caller already holds |
CI pipelines, service principals, workloads |
| Console | auth.oidc.providers[] |
the browser Authorization-Code + PKCE handshake | humans |
A GitHub Actions token must not be able to log in to the console, and an operator's Entra session must not be able to impersonate the signing workflow. Separating the axes is what makes that structural rather than a matter of careful group naming.
GitHub Actions job ──Bearer──▶ oidc.providers[github] ──▶ role: signer (artifact:sign only)
Entra service principal ─Bearer─▶ oidc.providers[entra] ──▶ role: issuer / auditor
Operator's browser ──PKCE──▶ auth.oidc.providers[entra] ──▶ role: admin / issuer / auditor
Partner auditor ─────PKCE──▶ auth.oidc.providers[partner] ─▶ auditor, in tenant "partner" only
Why a token from one issuer cannot borrow another's roles¶
Every provider carries its own role_mappings, and a mapping is evaluated only
for tokens the provider that declared it verified. That matters because a
subject string says nothing about who vouched for it: GitHub asserts
sub: repo:example-org/example-repo:environment:release, and any other issuer
can assert that exact string. With the mapping attached to the github provider,
an identical subject arriving from entra resolves to zero roles.
Three further rules hold the boundary:
- Routing is by
iss, and cannot pick a lenient verifier. The issuer used to select a provider is read from the unverified payload — sound, because the selected provider then checks the issuer cryptographically. A forgedissonly lands the token at a provider whose JWKS will not verify it. An issuer no provider serves is rejected outright. - Every provider is audience-bound.
client_id(plus anyaudiences) is the acceptedaudset, and a provider with neither is a startup error — it would otherwise honor any token the IdP minted for an unrelated relying party of the same tenant. - One issuer, one provider. Two providers may not share an issuer URL (routing would become order-dependent); names must be unique. Both are refused at load.
The trap: shared role_mappings¶
Mappings written directly under auth.oidc.role_mappings apply to every
console provider. That is what keeps the single-provider form working, but with
providers of differing trust it means a group name asserted by the partner IdP
would match a mapping meant for Entra. The example therefore declares no shared
mappings and puts every mapping on its provider. Do the same whenever the
providers are not equally trusted.
1. Register the clients¶
GitHub needs nothing registered — the audience is whatever your workflow asks for:
# .github/workflows/sign-release.yml
permissions:
id-token: write
environment: release # makes `sub` stable across tags — see below
Then core.getIDToken('secsy-pki-signing'), matching oidc.providers[github].client_id.
Use a GitHub Environment. Without one,
subembeds the git ref (repo:OWNER/REPO:ref:refs/tags/v1.2.3) and changes every release, so it cannot be pinned in config. Inside an environment it becomesrepo:OWNER/REPO:environment:release— stable, and gated by that environment's protection rules. Seeexamples/github-oidc-signing/for the full signing walkthrough.
Entra ID needs two app registrations, because the two axes are two clients:
| Registration | Type | Used for | Config |
|---|---|---|---|
api://secsy-pki |
Exposed API (app roles PKI.Issue, PKI.Audit) |
service principals calling the API with a Bearer token | oidc.providers[entra].client_id |
console-entra |
Web app, redirect https://pki.example.com/auth/callback |
the browser login | auth.oidc.providers[entra].client_id |
Add the groups claim to the console app's token configuration (or use app
roles and set groups_claim: roles, as the machine provider does here).
Partner IdP: one web-app registration with redirect
https://pki.example.com/auth/callback/partner. It is registered as a public
client (no secret shared with the partner), which is exactly the case
PKCE covers: the token request
carries client_id in the body and the code is bound to the verifier instead of
to a secret. Every console login here runs Authorization Code + PKCE (S256);
require_pkce: true under auth.oidc additionally refuses to start unless the
IdP advertises S256, and the partner provider relaxes it because that IdP omits
the metadata field.
2. Callback URLs — share one, or don't¶
Both console providers in this example could share
https://pki.example.com/auth/callback; partner is given its own path only to
show that it works. The name of the provider that began the flow is sealed inside
the HMAC-signed, 10-minute login transaction cookie alongside the state, nonce
and PKCE verifier, so the callback always knows which verifier and client
credentials to complete with.
GET /auth/login → the default provider (`default: true`, else the first)
GET /auth/login?provider=partner → that provider
GET /auth/login/oidc/partner → the same, as a path
GET /auth/callback → dispatched by the provider in the transaction cookie
GET /auth/callback/partner → a per-provider path, when an IdP demands a distinct URI
A transaction that arrives at a different provider's callback is refused with
{"code":"provider_mismatch"} rather than completed — otherwise a code issued by
one IdP would be exchanged with another's client credentials.
3. Run it¶
Startup names every provider it brought up:
OIDC Bearer verification enabled: provider="github" issuer=https://token.actions.githubusercontent.com audiences=[secsy-pki-signing]
OIDC Bearer verification enabled: provider="entra" issuer=https://login.microsoftonline.com/.../v2.0 audiences=[api://secsy-pki]
Interactive OIDC login provider "entra" enabled (issuer=https://login.microsoftonline.com/.../v2.0 redirect_url=https://pki.example.com/auth/callback)
Interactive OIDC login provider "partner" enabled (issuer=https://idp.partner.example.net redirect_url=https://pki.example.com/auth/callback/partner)
An IdP that cannot be discovered is logged as a WARNING and skipped, so one
provider's outage is not the server's. If no console provider comes up the
server refuses to start — SSO was configured and is wholly unavailable.
4. Check what a client should request¶
/api/auth/config is public (a browser must read it before it can log in) and
publishes both axes, so a pipeline discovers the issuer/audience pairing rather
than having it passed around out of band:
$ curl -s https://pki.example.com/api/auth/config | jq
{
"oidc_enabled": true,
"oidc_login_enabled": true,
"webauthn_enabled": true,
"oidc_providers": [
{ "name": "entra", "display_name": "Microsoft Entra ID", "login_url": "/auth/login?provider=entra", "default": true },
{ "name": "partner", "display_name": "Partner IdP", "login_url": "/auth/login?provider=partner" }
],
"bearer_providers": [
{ "name": "github", "issuer_url": "https://token.actions.githubusercontent.com", "audiences": ["secsy-pki-signing"] },
{ "name": "entra", "issuer_url": "https://login.microsoftonline.com/.../v2.0", "audiences": ["api://secsy-pki"] }
]
}
No client secret ever appears there. The console reads this document and renders one sign-in button per provider, so an operator picks their IdP instead of being sent to the default.
5. Confirm the isolation holds¶
The two checks worth running after the first successful login of each kind:
# The console session reports which IdP authenticated it.
$ curl -s --cookie "secsy_session=…" https://pki.example.com/api/me | jq '{sub, roles, auth_provider}'
{ "sub": "…", "roles": ["admin"], "auth_provider": "entra" }
# The tamper-evident event log separates logins per provider (console Audit
# page, or the API's server-side action filter).
$ curl -s --cookie "secsy_session=…" \
"https://pki.example.com/api/events?action=auth.login&limit=20" \
| jq -r '.entries[] | "\(.actor)\t\(.detail)"'
op@example.com method=oidc provider=entra
auditor@partner.example.net method=oidc provider=partner
And the negative case: an Entra service-principal token that asserts the GitHub
workflow's sub authenticates (it is a valid Entra token) but resolves to no
roles, so POST /api/sign answers 403. That is the whole point of the
per-provider mapping.
Security properties¶
- No stored secret for CI. The pipeline's credential is a per-run, minutes-long OIDC token.
- Least privilege per population.
signergrants exactlyartifact:sign; it can never mint certificates. Partner auditors holdauditorwithin one tenant and nothing platform-wide. - Provider-scoped authorization. A grant to one issuer's identity is unclaimable by another issuer asserting the same subject.
- Audience-bound verification. A token minted for a different relying party of the same IdP does not authenticate here.
- Code interception closed. Console logins are Authorization Code + PKCE
(S256) with the verifier sealed in an
HttpOnlycookie, so an authorization code that leaks cannot be redeemed by whoever picks it up — the protection that lets the partner's client hold no secret at all. - Break-glass closed last.
policy.allow_root_basic_auth: trueis left on while you verify SSO; turn it off once both IdPs work, since a shared static password is then the weakest credential in the deployment.