AlgoJudge documentation0.1

Identity

The sign-in addresses, how a federated account is keyed, what a claim may never buy, and every way a sign-in is refused.

An installation may trust several OIDC providers at once — a university's SSO, auth.algojudge.app, whatever else an operator adds. Each is a row an administrator edits from the panel rather than a file on disk.

The Server sees a plain OIDC provider and nothing more.

The Server never learns which identity product is behind a provider

Two identity deployments are supported, and neither is named anywhere in the model: there is no field, branch or discriminator for either. What differs between them is which of the optional switches an operator sets, and every one of those already exists.

The evidence is a change that did not happen. Measured 2026-08-27: one deployment gained an account-deletion back channel built a completely different way from the other's, and not one line of the Server changed. A model carrying a field for how this product reports a deletion would have had to.

The addresses

Three, and all of them anonymous — nobody is signed in yet, which is the point. The guard is the registration: a slug that is unknown or disabled is not found, and answers 404.

GET /identity/providers/{slug}/challengestarts a sign-in. returnUrl is a local path, or it falls back to /
GET /identity/providers/{slug}/callbackthe handler's own address, and the redirect URI a provider is given
GET /identity/providers/{slug}/signed-inwhere a validated ticket lands and this Server decides what it means

They sit under the API's path base, so the redirect URI begins /api/v1/. It is computed and returned rather than written by hand: a literal path that omits the base validates perfectly and then sends the browser to an address nobody serves.

returnUrl is local-only, and that is not tidiness. An open redirect on a sign-in endpoint is a phishing primitive, because the link really is ours.

A federated identity is keyed on issuer plus sub

UserIdentity links one account to one identity at one provider, and it is unique on (providerId, subject).

Never on the email address

An address is something a person changes at their provider. A federation keyed on it hands the account to whoever inherits the address. sub is the only value a provider promises is stable and its own.

Two consequences follow directly:

  • An address changed at the provider does not repoint an account. The link is unaffected; the address is decoration.
  • Changing a provider's Issuer repoints every user it has. It is half the key.

Subject is opaque: never parsed, never displayed as though it meant something.

An account may hold several links — a university login and auth.algojudge.app are two ways into the same person — and losing one does not end the account while another way in remains.

One person, one account, whichever door they came through

The rule is about the outcome. The mechanisms that reach it are deliberately conservative.

Provisioning never attaches to an existing account by name or by address. That is the whole reason the key is issuer plus sub: a provider that could hand over a preferred_username matching somebody else's login would be handing itself that person's account. A taken login is decorated until it is free.

An address already on another account is dropped rather than refused. Two providers vouching for the same person will hand over the same address; addresses stay unique across accounts, so the second account is provisioned without one. It reads as a missing field and is one. What it is not is a reason to refuse somebody a directory has already vouched for.

Automatic correlation on an unverified address is account takeover

It is not a convenience, and the code says so where it would have been easiest to do: an LTI roster with nothing to match on correlates on nothing rather than on the address it was given.

An LTI launch for somebody the tool cannot name resolves to nobody, and the way forward is for that person to sign in through SSO and come back — which writes the same link by a route the platform cannot forge. A launch that asserts a different person for an existing link is reported as a conflict and never followed.

Bringing two accounts together after the fact is a deliberate administrative act, user:merge, with an undo window. It is not something a sign-in does.

What a sign-in does, in order

The order is the decision, and it is the part most likely to be built backwards.

  1. Validate the token and find the subject. No subject, no sign-in.
  2. Resolve the provider's mapping into a permission set.
  3. Apply the permission change.
  4. Then decide whether to admit.

Step 3 before step 4 is deliberate. A sign-in that is going to be refused still has to withdraw what the provider no longer grants — the refusal means "this directory no longer says you are staff", and leaving yesterday's contribution in place would keep somebody staff for ever by the simple method of never signing in again.

Every path through this is idempotent: the same token twice leaves the same rows.

When a sign-in is refused

A refusal is a redirect and not a status code: the browser is mid-journey and there is nobody to read a JSON body. The reason travels as a code the sign-in screen turns into a sentence, at /login?provider=<slug>&error=<code>.

provider.ticket.missingthe callback carried no validated ticket
provider.ticket.mismatchthe ticket names a different provider
provider.subject.missingthe token carried no sub
provider.disabledswitched off between the challenge and the callback
provider.unmappednothing matched, under Deny
provider.refuseda refusal with nothing more specific to say

The external cookie is signed out before any of them is decided. A refusal that left one behind would let a later request be mistaken for a fresh ticket.

Registering or editing one refuses at the write instead, and names why: provider.slug.invalid and provider.slug.taken; provider.displayName.required; provider.issuer.required, .invalid and .insecure; provider.clientId.required and provider.clientSecret.required, the second on creation only; provider.claimPath.invalid; provider.unmappedBehavior.unknown and provider.defaultTemplate.required; provider.deletionSecret.required for a channel enabled without one; six provider.rule.* for a rule with no claim value, a value mapped twice, a template missing, unknown, carrying system:administrator, or carrying permissions its author does not hold; and provider.linked, which refuses to delete a provider people still sign in through.

The provider row

FieldMeaning
Slugstable, lowercase, URL-safe. It appears in the sign-in path, and renaming one breaks every sign-in through it — see below
DisplayNamewhat the sign-in button says. Changed freely
Issuerhalf of the federated key. Discovery finds everything else from it
ClientId
ClientSecretwrite-only
Scopesspace-separated. openid is always requested whether or not it is listed
Enableddisabled hides it from the sign-in screen and refuses its callback
AccountUrlwhere a person edits their own details, because they cannot edit them here
DeletionUrlwhere a person deletes their account at the provider
ClaimPathwhere in the token the mapped value lives, as a dotted path
UnmappedBehaviorDeny (the default) or DefaultTemplate
DefaultTemplateNamewhat DefaultTemplate grants
DeletionChannelEnabledwhether this provider may report a deleted account over the back channel
DeletionSecretwrite-only

ClientSecret and DeletionSecret are returned by nothing: no endpoint discloses them, no projection carries a field for them, and the panel shows whether one is set rather than what it is.

A provider secret is stored in plaintext, by decision

Encrypting it would have relocated the secret rather than removed it — the key has to live outside the database, or a backup carries both halves. So the exposure is stated instead of engineered around: a database backup carries a usable provider credential and has to be handled as one.

AccountUrl and DeletionUrl are configuration, not discovery: OIDC standardises no such URL, so there is nothing to look up and a guess would send people to a 404 on somebody else's domain. They are two different addresses for two different acts, and sending somebody who wants to leave to a profile editor is the kind of helpfulness that reads as a runaround.

Disabling a provider does not withdraw what it has already contributed. That is a decision about people's access, and turning a provider off to reconfigure it should not silently demote everybody who signed in through it.

Renaming a slug refuses every sign-in through it

The slug is stamped on the challenge when a sign-in starts and checked when the provider sends the browser back. Change it and every sign-in in flight — and every one afterwards, until the redirect URI is changed at the provider too — is refused with provider.ticket.mismatch.

That check is not tidiness. Every provider shares one external cookie, so without it the slug in the returning URL would decide whose claim mapping was applied to whose claims: a ticket issued by one provider could be redeemed at another's address. The slug is cheap to choose and expensive to change, and it is worth choosing it as though it were permanent.

Mapping is configuration, not code

A provider carries one ClaimPathgroups, realm_access.roles, whatever that provider emits — and a list of rules. One rule is one line of an allowlist: this claim value grants this template.

A dotted path, never an expression. An expression in provider configuration is code executed against the contents of a token, and a token is written by somebody else. There is no pattern, no prefix, and nothing evaluated: ClaimValue is compared by exact string equality. A wildcard in an allowlist is how an allowlist stops being one.

Two claim shapes are both ordinary and both handled: several claims with the same name, and one claim whose value is a JSON array. An installation should not have to know which its provider emits.

An empty rule list is a legitimate state and means every sign-in falls to UnmappedBehavior. Under Deny with nothing matched, no account is created — provisioning somebody the mapping refuses would leave an account that can never be used and that an administrator has to explain.

Deny is safe as a default only because the Server keeps its own Identity permanently: a claim path somebody mistyped cannot lock an installation out, because an administrator still signs in locally.

A mapping rule is the one live reference to a template

Choosing a template for a grant copies its permissions and nothing points back. A mapping rule is different: the contribution is re-derived from it at every sign-in, so editing the template a rule names does reach people, at their next sign-in. Deleting a named template is refused.

The two guards

A claim never mints privilege on its own

system:administrator is unreachable through a mapping, in every configuration, and nobody may map onto a permission they do not themselves hold.

Both are enforced twice, and the second time is the point. The write path refuses a rule pointing at a template that carries system:administrator, or at permissions its author does not hold. The mapping then filters again when it is used — because a template can be edited after a rule names it, and "unreachable in every configuration" has to mean at the moment the mapping is used, not only at the moment it was written.

A permission the catalogue does not describe is stripped at the same point. A template naming a key this Server has never heard of would otherwise be stored into a grant, and an unknown key counts as staff — so a typo in a template would quietly take somebody out of a ranking.

The contribution a provider writes

A provider's contribution is a system-scope grant carrying SourceProviderId. It is rewritten at every sign-in and is not editable by hand: editing one would last until that person next signed in, and a change that silently reverts is a change nobody can trust.

At system scope a user's permissions are the union of the manual contribution and one per linked provider.

Mapping into an activity does not exist. That belongs to the LTI work, whose purpose is to mirror a course binding.

The account the Server provisions

It carries no password, which is what makes it not a local account — the profile is read-only here and belongs to the provider.

It is approved on arrival, because the provider is the decision. An account that a trusted directory created and that then sat pending would need an approval nobody was told to make, which turns a launch gate into a support queue.

EmailConfirmed is set only when the token said email_verified was true.

On this page