Scopes and claims¶
A scope is a name for what a client may obtain, and a claim is one thing it learns about a person: a scope of
OpenID Connect stands for a set of claims (OIDC Core §5.4), and the claims parameter asks for claims one by
one (OIDC Core §5.5). What a client receives is what its request asked for and the provider holds a value
of: the claims of the scopes the client registered and the person agreed to, and the claims it named in
claims. A claim named in claims is served whichever scopes the client registered or the person agreed
to, and the consent page lists scopes and authorization details, not the claims named this way.
Scopes¶
| Scope | What it stands for |
|---|---|
openid |
That the request is an OpenID Connect authentication. Every authorization request carries it, or is refused with invalid_scope |
profile |
name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, updated_at |
email |
email, email_verified |
address |
address |
phone |
phone_number, phone_number_verified |
offline_access |
A refresh token (OIDC Core §11). Refresh tokens say when one is issued without it |
| Any other | Nothing the provider serves: a scope a client registered, which travels in the scope of the access token for the resource server that reads it |
A client asks for what it registered. scope is a list of scope tokens separated by single spaces
(RFC 6749 §3.3), each made of visible ASCII other than " and \, and a scope named twice counts once. The
scope a client registered is the closed list of what it may ask for: scopes are compared byte for byte, and
a request that names one outside the list is refused with invalid_scope.
The document says what the tenant hands out. scopes_supported lists openid first, then the scopes the
wired claims providers stand for, then every scope a client of the tenant registered, then offline_access
when a wired grant issues a refresh token and a wired grant spends one. A scope that stands for no claim
exists because a client registered it, so a typing mistake in a registration shows in the document rather
than being granted in silence. A client that registers itself is held to that
list: a scope the tenant does not hand out is refused at its registration.
Where a claim goes¶
| Document | What it carries about the person |
|---|---|
| The ID token | sub, the claims of the protocol (auth_time, acr, amr, sid and the others), and the claims the request asked for by name in claims.id_token. Nothing a scope stands for |
| The UserInfo answer | sub, the claims the scopes of the access token stand for, and the claims the request asked for by name in claims.userinfo, whatever their scope |
So a client reads the claims of profile or email at UserInfo, and in its ID token only the ones it asked
for by name. The names asked in claims.userinfo travel with the access token, in its private claim
userinfo_claims for the self-contained shape, so UserInfo knows them when the token comes back. In the ID
token, the claims of the protocol win over a claim of the same name a provider of claims returns; in the
UserInfo answer, sub does.
The claims parameter¶
{
"id_token": {
"email": null,
"acr": { "essential": true, "values": ["phr", "2"] }
},
"userinfo": {
"phone_number": null
}
}
claims is a JSON object, URL-encoded in the request, whose members id_token and userinfo each map a
claim name to null or to an object with essential, a JSON boolean, value, a string, a number or a
boolean, and values, a non-empty array of those (OIDC Core §5.5.1). A member other than the two is ignored.
A name made of digits, a member of another shape, a document nested deeper than a request needs or anything
that is not JSON is refused with invalid_request at the redirect URI. claims_parameter_supported is true.
What is asked for is served by name, with or without its scope, when the person has a value for it.
essential, value and values say nothing to the provider of claims; the one claim they decide is acr,
whose values name the classes of authentication a client accepts and whose essential makes the request
binding (interactions).
Where a claim is read from¶
The claims of OIDC Core §5.1 are read from the account of the person in the core (users):
| Claim | Read from |
|---|---|
name, given_name, family_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale |
The profile of the account, one field each |
email, email_verified |
The address of the account, and whether it was verified |
phone_number, phone_number_verified |
The phone number of the account, and whether it was verified |
address |
The postal address of the account, as an object of the members it holds among formatted, street_address, locality, region, postal_code and country (OIDC Core §5.1.1) |
updated_at |
When the account last changed, in seconds since the epoch |
A claim the account holds no value for is left out, its verification flag with it; a flag beside a value is a
JSON boolean, true or false, never a string. sub is not among them: it comes from the kind of subject
the client registered (pairwise subjects). claims_supported lists sub and every
claim above.
Claims of your own¶
A deployment that serves claims the core does not hold, and the scope that stands for them, adds a class
beside the one that serves the claims of §5.1, implementing Beffroi\Oidc\Port\ClaimsProviderInterface and
tagged beffroi.oidc.claims_provider:
<?php
declare(strict_types=1);
namespace App\Claims;
use Beffroi\Core\Domain\Identity\User;
use Beffroi\Oidc\Domain\Scope\Scope;
use Beffroi\Oidc\Domain\Scope\Scopes;
use Beffroi\Oidc\Port\ClaimsProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('beffroi.oidc.claims_provider')]
final readonly class EmployeeClaims implements ClaimsProviderInterface
{
/**
* @param EmployeeDirectory $directory where this deployment keeps its employee numbers
*/
public function __construct(
private EmployeeDirectory $directory,
) {
}
public function claims(User $user, ?Scopes $scopes, array $requested): array
{
$due = isset($requested['employee_number']) || ($scopes?->has(new Scope('employee')) ?? false);
$number = $due ? $this->directory->numberOf($user->subject->value) : null;
return $number === null ? [] : ['employee_number' => $number];
}
public function supportedScopes(): array
{
return [new Scope('employee')];
}
public function supportedClaims(): array
{
return ['employee_number'];
}
}
$scopes is null when the document is an ID token, which carries what was asked for by name and nothing a
scope stands for, and $requested holds the names asked for by the request, for the document being written.
A claim without a value is left out of what the method returns. What the class serves is announced in
scopes_supported and claims_supported, and a client registers the scope as it registers any other. Two
providers that both return a value for one claim are refused when they meet; two that stand for the same scope
are not, a scope being a name for a set of claims.