External auth
External auth
OAuth and OpenID Connect sign-in for barakoCMS. A user arriving through a provider is matched to a global user by verified email (an OpenID Connect provider by issuer and subject after the first time) and issued exactly the same tenant-scoped, device-bound token as the built-in flows. Social sign-in is another way in, not a second, weaker way in.
Enable it
dotnet add package BarakoCMS.ExternalAuth
builder.Services.AddBarakoCMS(builder.Configuration);
var app = builder.Build();
app.UseBarakoCMS();
The package reference plus a restart is the install. AddBarakoCMS finds every module in the
application's dependency context, and BarakoCMS:Modules:Enabled decides which of them run
(BarakoCMS__Modules__Enabled=ExternalAuth). Unset, every referenced module runs and the API logs
one warning saying so. To name it by hand instead, put
modules.Add(new BarakoCMS.ExternalAuth.ExternalAuthModule())
in the AddBarakoCMS callback; discovery skips a type the host already added. See MODULES.md in
the repository.
Endpoints
| Method & path | Purpose |
|---|---|
GET /api/auth/providers |
Which providers are configured, for rendering buttons |
GET /api/auth/{provider}/start |
Begin the OAuth handshake |
GET /api/auth/{provider}/callback |
Provider redirect target |
GET /api/auth/oidc/{name}/start |
Begin sign-in with a provider configured under Oidc:Providers |
GET /api/auth/oidc/{name}/callback |
That provider's redirect target |
POST /api/auth/oidc/{name}/callback |
The same, for a provider set to ResponseMode form_post (Apple) |
POST /api/auth/oidc/{name}/id-token |
A native app exchanges the provider's id token for a barako token |
GET /api/me/profile |
Profile details captured from the provider |
{provider} is google, github, facebook or linkedin. Only configured providers are
advertised or accepted.
GET /api/auth/providers answers with a boolean for each of those four and an oidc array, one
{ "name", "displayName" } for each OpenID Connect provider that is on. The array holds at most 20.
Configuration
{
"Google": { "ClientId": "...", "ClientSecret": "..." },
"GitHub": { "ClientId": "...", "ClientSecret": "..." },
"LinkedIn": { "ClientId": "...", "ClientSecret": "..." },
"Facebook": { "AppId": "...", "AppSecret": "..." }
}
Each provider is its own section at the root of the configuration, not under ExternalAuth. Omit a
provider to leave it disabled. "Enabled": false inside a provider's section turns that provider
off while keeping its keys, and ExternalAuth:Enabled set to false turns every provider off.
OpenID Connect providers
Any provider that publishes a discovery document can be added by configuration: Keycloak, Auth0, Cognito, Microsoft Entra ID and others.
{
"Oidc": {
"Providers": {
"keycloak": {
"Authority": "https://id.example.com/realms/main",
"ClientId": "...",
"ClientSecret": "...",
"DisplayName": "Company sign-in"
}
}
}
}
| Setting | Meaning |
|---|---|
Authority |
The issuer URL. It must be https. /.well-known/openid-configuration is fetched from it. |
ClientId, ClientSecret |
Both required, unless SignedClientSecret stands in for the secret. A provider with no client id is off; one with no secret is off and a startup warning says so. |
SignedClientSecret |
Optional. KeyId, TeamId and PrivateKey (a P-256 key in PEM), and optionally Audience. The client secret is then an ES256 JWT made from them. See Apple below. |
ResponseMode |
Optional. query (the default) or form_post, for a provider that posts the code back. |
IdTokenAudiences |
Optional. The audiences the id token grant accepts, as a list or one comma-separated value. The grant is off without it. See below. |
EmailVerifiedMayBeText |
Optional. true also takes the text "true" for the verified claim. For Apple only. |
DisplayName |
Optional. What /api/auth/providers reports for the button. Defaults to the name. |
Scopes |
Optional. Defaults to openid email profile. openid is added if it is left out. |
Issuer |
Optional. What the discovery document's issuer has to equal, when that is not the authority. See Microsoft below. |
EmailVerifiedClaim |
Optional. The id token claim that says the provider vouches for the email. Defaults to email_verified. It must be a claim only the provider sets, never a profile attribute a user can edit. Only the JSON boolean true counts, unless EmailVerifiedMayBeText is set. |
Enabled |
false turns the provider off and keeps its keys. |
The name is the key under Providers: 1 to 32 characters of a-z, 0-9 and hyphen. It is the
{name} in the routes. Register {App:BaseUrl}/api/auth/oidc/{name}/callback as the redirect URI
at the provider. At most 20 providers are read.
The issuer is compared exactly, so write Authority the way the provider's discovery document
writes its issuer, trailing slash included (Auth0 has one).
What the flow checks:
- State, nonce and PKCE. The start mints all three and keeps them in one HttpOnly, Secure
__Host-cookie for ten minutes. The callback needs the returnedstateto match, sends the PKCE verifier with the code, and needs the id token'snonceto match. A state is accepted once: the cookie is expired by every callback, and the instance that saw a state refuses to see it again for those ten minutes. That memory is per instance, so behind several instances a replay that reaches another one is refused by the provider, which redeems a code once. It holds 10,000 states; when that many are all still live it is cleared whole, and until it fills again a replay is likewise left to the provider. The cookie holds the three values in clear and is not signed: it is a double-submit cookie, and what protects it is the__Host-prefix with HttpOnly, Secure and SameSite=Lax. For aform_postprovider it is SameSite=None instead, because a browser does not send a Lax cookie on the provider's cross-site POST. The state check is the same: a third site can post a code and a state, but it cannot read or set this cookie, so the state it posts matches nothing. - The id token. Signed with one of
RS256,RS384,RS512,PS256,PS384,PS512,ES256,ES384orES512.noneand the HMAC algorithms are refused. The token'skidhas to name exactly one key in the provider's key set, and the signature is checked against that key only. A token with nokidis accepted only when the provider publishes a single key and its issuer is not a template.issequals the configured issuer exactly,audcontains the client id, with more than one audienceazphas to be the client id, andexpandnbfhold with 60 seconds of skew.pictureis kept only when it is an https URL. - Outbound calls. The discovery document, the keys and the code exchange go through the same guarded HTTP client the core uses for webhooks: https only, no redirects, and no loopback, private, link-local or metadata address. Each call has a 10 second timeout. Discovery and keys are cached for an hour, a key id the cache does not hold refetches the keys at most once in five minutes, and a failed fetch is not retried for 30 seconds. If the key endpoint cannot be reached when the hour is up, the keys already held go on being used until they are four hours old. After that every token is refused until the keys can be fetched again. Discovery has no such grace: it fails at the hour.
- Where the browser goes. The redirect URI is
App:BaseUrlplus the route. Nothing in the request changes it, and after sign-in the browser is sent toApp:BaseUrlonly. - Rate limit. Start, both callbacks and the id token grant share 20 requests per five minutes
per client address.
Oidc:RateLimit:PermitLimitandOidc:RateLimit:WindowSecondschange it.
Only the id token is read. The userinfo endpoint is not called, so a provider that puts the email
only there signs nobody new in. The token endpoint must accept client_secret_post or
client_secret_basic.
Which account a sign-in belongs to
A provider account is identified by issuer and subject, never by email alone. The pair is stored
the first time (mt_doc_external_identities), and every later sign-in with that pair is the same
user, whatever email the token then carries.
The first time, the pair has to be tied to a user, and that is done by email only when the provider asserts the address is verified. Then it is linked to the user who holds that address, or to a new user. When the claim is absent or false the sign-in is refused, nothing is stored, and the person is told to use an email code.
So every provider you configure can sign in as any local account whose address it asserts as verified, a SuperAdmin included. Configure only providers you trust to verify email, and remember that a provider you run yourself (a Keycloak realm, for instance) is as trustworthy as whoever can edit its users. Two providers that assert the same address land on the same account. An account with MFA enrolled is still asked for its second factor.
Linked accounts
A platform administrator (capability manage_users, SuperAdmin) lists a user's provider links with
GET /api/users/{userId}/external-identities and removes one with
DELETE /api/users/{userId}/external-identities/{id}. A tenant administrator cannot: a link belongs
to the person and works in every tenant they sign in to. An unknown user, or a link that belongs to
another user, answers 404. The provider's subject is not returned. The next sign-in through that
provider links again by verified email.
Mapping a provider claim to roles
A tenant sets one map with PUT /api/tenants/external-role-map (GET and DELETE read and remove
it, and each change is written to the audit log). The map is one claim name from the id token, for
example groups, and rules from a claim value, read from one provider, to a role:
{ "claim": "groups", "rules": [ { "provider": "azure", "value": "staff", "roleId": "<role id>" } ] }
provider is the name under Oidc:Providers and must be configured. A rule only matches a token
that came through that provider, so the same group name at another provider gives nothing. Values
match exactly and case sensitively, and a value cannot start or end with a space. A rule cannot read
name, given_name, family_name, nickname, picture, preferred_username, email,
email_verified, sub, iss, aud, phone_number, address, website, profile,
middle_name, gender, birthdate, locale, zoneinfo or updated_at. A map stored some other
way on one of them is ignored at sign-in.
A rule can also carry issuer, which must equal the token's issuer exactly. It is required when the
provider's issuer is a template, as with Entra common or organizations: there the provider name
matches a token from any directory, and any directory's administrator could assign themselves a
value the rule names. Give the issuer with the directory filled in, such as
https://login.microsoftonline.com/<tenant id>/v2.0. The PUT answers 400 without it.
On every OpenID Connect sign-in for that tenant, each rule the token matches adds its role to the
person's membership, creating the membership if there is none. A person with a second factor
enrolled is given nothing at first factor. The roles the map earned travel in the signed MFA
challenge, and /api/auth/mfa/verify grants them once the code passes, after checking them again
and that the membership is still Active. A wrong code, an expired challenge or a tampered one grants
nothing. The grant commits with the outcome of
the sign-in, the spent nonce included, or not at all. It only adds. A removed or suspended member is
not brought back, and a claim that stops being sent takes nothing away. Taking a mapped role away
means removing it from the member by hand, and the map adds it back at their next sign-in unless the
map is edited too. The map is read from the sign-in's tenant, never
from the request.
It cannot name SuperAdmin, a role carrying a platform capability, a role of another tenant, or a role that does not exist; the PUT answers 400, and a map stored some other way is filtered the same at sign-in. A map can create a membership in a tenant whose sign-up is Closed, for a person who already has an account: Closed stops people signing themselves up, and the map is the administrator's own rule.
What is read from the token is bounded. Only top-level claims that are a string or an array of
strings count, at most 64 claims, 100 values for each, and a value longer than 255 characters is
dropped. Nested claims, such as a Keycloak realm_access.roles, are not supported; ask the provider
to send a top-level claim. The table is created by migrations/5.1.0/915-external-auth-role-maps.sql.
An upgraded database needs migrations/4.6.0/external-auth-identities.sql before the new build
starts, like any new table.
Microsoft Entra ID
One directory is plain configuration: Authority is
https://login.microsoftonline.com/{directory id}/v2.0.
For the multi-directory endpoints (common, organizations) the discovery document's issuer is a
template, https://login.microsoftonline.com/{tenantid}/v2.0, and each token's iss names its own
directory. Set Issuer to that template, written with the literal {tenantid}:
"microsoft": {
"Authority": "https://login.microsoftonline.com/common/v2.0",
"Issuer": "https://login.microsoftonline.com/{tenantid}/v2.0",
"ClientId": "...",
"ClientSecret": "...",
"EmailVerifiedClaim": "xms_edov"
}
The token's tid has to be a GUID, iss has to equal the template with tid in place of
{tenantid}, and a signing key that names an issuer has to name that same one. The account is
stored under the directory's own issuer, so the same subject in two directories is two people. Any
directory can sign in through common; use the one-directory authority to accept only yours.
Microsoft does not send email_verified. xms_edov is its optional claim for an address whose
domain owner is verified; add it, and the email claim, to the app registration's id token.
Without it a first sign-in is refused as unverified.
This is tested against a stub that publishes a template issuer, not against Microsoft.
Apple
"apple": {
"Authority": "https://appleid.apple.com",
"ClientId": "com.example.web",
"Scopes": "openid name email",
"ResponseMode": "form_post",
"EmailVerifiedMayBeText": true,
"SignedClientSecret": {
"KeyId": "ABC123DEFG",
"TeamId": "TEAM456789",
"PrivateKey": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
}
}
ClientId is the Services ID. Register {App:BaseUrl}/api/auth/oidc/apple/callback as its return
URL. Apple posts the code back as a form when the name or email scope is asked for, so
ResponseMode is form_post, and the callback is then a POST as well as a GET.
Apple takes no fixed client secret. PrivateKey is the .p8 key from the developer account, as
downloaded; written on one line, \n is read as a line break, which is how an environment variable
can hold it. The secret sent with the code exchange is an ES256 JWT with kid the key id, iss the
team, sub the client id and aud the issuer (Audience changes that). It lives 30 days, Apple's
limit being six months, and a new one is made when a day is left. A key that is not a P-256 private
key turns the provider off with a startup warning, rather than sending no secret.
Apple documents email_verified as a boolean or the text "true", so EmailVerifiedMayBeText
takes both. Leave it off for other providers. Apple posts the person's name only on the first
sign-in, in a user field; it is not read, and the id token carries no name.
This is tested against a stub shaped like Apple, not against Apple.
Native apps: the id token grant
A MAUI or mobile app that signs in with the provider's own SDK already holds an id token. It exchanges it here, with no browser:
POST /api/auth/oidc/{name}/id-token
{ "idToken": "...", "nonce": "...", "club": "optional-club" }
The answer is { "token", "refreshToken", "requiresMfa", "mfaChallengeToken" }: the same tokens
the callback issues, or an MFA challenge to finish at /api/auth/mfa/verify.
The grant is off for a provider until IdTokenAudiences lists the client ids its tokens may be
issued to. A native app's client id differs from the web one, so the list replaces ClientId here
rather than adding to it. With several audiences in a token, azp has to be in the list too.
Google's native tokens name the server's (web) client as aud and the app's client as azp, so for
Google list both the web client id and each app's. A token the web sign-in was issued also has the
web client as aud, with azp the web client or absent, and must not be exchanged here. So when a
token's aud is the provider's ClientId, the grant also needs azp to be a listed client that is
not the ClientId.
The token is checked as the callback checks it: issuer, signature against the issuer's keys,
audience, expiry, email_verified. The nonce is required: the app makes a random one, gives it
to the SDK, and sends the same value here, which has to equal the token's nonce claim exactly. If
the SDK takes a hash of the nonce (Apple's iOS SDK does), send what the token carries. Each nonce
works once. It is recorded (mt_doc_oidc_used_nonces) in the same commit as the sign-in and
refused until the token it came in has expired, so a token that leaks from the app cannot be
exchanged again, while a sign-in that fails (a 409, a transient error) leaves the token usable for
a retry. A 403 or an MFA challenge does spend it. The nonce is queued only for the commit that
records the outcome, so an earlier commit (trusting the device, with the DeviceTrust module on)
does not spend it. A token that expires more than a day ahead is
refused, which bounds how long a record is kept. Expired records are deleted at most every five
minutes, up to 5,000 at a time.
| Answer | When |
|---|---|
| 200 | Signed in, or requiresMfa |
| 400 | No id token, or a nonce that is not 16 to 256 printable characters without spaces |
| 401 | The token is refused, the nonce was already used, or the email is not verified |
| 403 | Not a member of the club asked for |
| 404 | No such provider, or its IdTokenAudiences is empty |
| 409 | Two first sign-ins for one address raced; try again |
| 503 | The provider's discovery document cannot be fetched |
An upgraded database needs migrations/4.8.0/external-auth-used-nonces.sql before the new build
starts.
Security notes
Matching is on a verified email only. Matching on an unverified one would let anyone who can claim an address at a provider take over the matching account. Take 0.4.0 or later: this was the documented intent from the start and the enforcement was missing, so Google, LinkedIn and Facebook never read a verification flag, and GitHub read one only for accounts whose address was private on their profile.
What each provider is asked for now:
Provider Source of truth Behaviour Google email_verifiedon the OIDC userinfo responseRefused when absent or false LinkedIn email_verifiedon/userinfoRefused when absent or false GitHub verifiedon/user/emailsOnly the verified primary is used; the unflagged profile email is ignored Facebook none exists Refused unless Facebook:TrustUnverifiedEmailis setOpenID Connect email_verifiedin the id token, or the claim named byEmailVerifiedClaimA first sign-in is refused when absent or false. A provider account already linked by issuer and subject signs in without it Facebook is opt-in. The Graph API exposes no per-field verification flag, so there is nothing to check and no honest way to claim the address is verified. Setting
Facebook:TrustUnverifiedEmailtotruesays you have decided Facebook's own verification is good enough for your deployment, and accepts that a Facebook account asserting an address becomes a login for the local account holding it. It defaults to off, which refuses the sign-in.If the account has MFA enrolled, the provider callback issues an MFA challenge rather than a session token. Take 0.1.6 or later: earlier versions minted a token directly, so a provider-account takeover skipped the second factor entirely.
Part of barakoCMS
This is an optional module for barakoCMS, an open-source
headless CMS for .NET 10. Every module is published under the barakocms-module tag, so a single
search on nuget.org returns the whole set.
Contributions are welcome — including a module icon or other design work. See CONTRIBUTING.md.
Licensed under MIT.
If barakoCMS is useful to you, a star on the repository helps other people find it.