Last changed

Signing in with a dasmaffin.com account

This site is an OpenID Connect provider. A program somewhere else sends somebody here, they sign in the way they already do, and the program is handed a token saying who they are.

It is a plain, standard provider. Any OpenID Connect library will work against it, and you should use one rather than writing the flow by hand.

What to configure

Issuer https://www.dasmaffin.com/
Discovery https://www.dasmaffin.com/.well-known/openid-configuration
Key set https://www.dasmaffin.com/.well-known/jwks
Flow Authorization code, PKCE with S256, public client, no client secret
Signing RS256

Point your library at the issuer and let it read the rest from the discovery document. Do not copy the endpoint addresses into your configuration: the key set in particular is meant to be fetched, because the keys in it change.

Fetch the discovery document from the issuer's own address - the www one. The endpoints it advertises are built from the address you asked at, so fetching it from anywhere else gives you a document whose endpoints and whose issuer disagree, and a strict client is right to refuse that.

Getting registered

Send these. They go in src/Host/oidc-clients.json in the site's repository, which deploys with the site:

  • A client id. Short, lowercase, and yours forever - it is never reused for anything else.
  • A display name. What the person is asked about, in the words they would recognise.
  • Every redirect address, in full. Matched exactly, character for character. A custom scheme (com.example.yourapp:/oauth2redirect) is fine and is the normal answer for an app on a phone.
  • Which scopes you need, out of the list below.
  • Whether you need to stay signed in between sessions.

There is no self-service registration page, and no client secret to collect: everything above is public by construction, since a client id and a redirect address both travel in the address bar during any sign-in. Registering you is a commit and a deploy.

Exact matching is the point, so send the addresses as your app will actually send them. A trailing slash is a different address. If you need another one later, that is another commit - it is not something the app can add for itself, deliberately.

Redirect addresses, and the one that catches native apps out

Matching is exact. Only the form that was registered is accepted, and near-misses are refused with a 400 rather than being tidied up. Measured against the live site for the registered com.dasmaffin.cairn:/oauth2redirect:

Sent
com.dasmaffin.cairn:/oauth2redirect accepted
com.dasmaffin.cairn://oauth2redirect refused
com.dasmaffin.cairn://oauth2redirect/ refused
com.dasmaffin.cairn:/oauth2redirect/ refused
com.dasmaffin.cairn://oauth2redirect/extra refused

But the address you are sent back to is the normalised one, with two slashes and a trailing slash:

com.dasmaffin.cairn://oauth2redirect/?code=...&state=...

So an app sends one form and must catch another. On Android that means an intent filter matching host oauth2redirect and path / - the scheme-only filter AppAuth sets up by default is fine, and one that pins android:path="/oauth2redirect" will never fire. It does not present as an error: the site redirects, nothing catches it, and the sign-in simply hangs.

A reverse-DNS scheme is a convention for not colliding with somebody else, not a claim of ownership, so it does not have to match your application id.

Scopes

Scope What it is
openid Required. Asks who they are, and gets you sub and nothing else.
profile The display name on their profile, as name and preferred_username.
email Their email address and whether it has been confirmed.
offline_access A refresh token, so they stay signed in between sessions.

Ask for what you need. Every scope is a line on the consent screen the person reads before they press Allow, and a shorter list is one people say yes to.

What is in the identity token

{
  "iss": "https://www.dasmaffin.com/",
  "aud": "your-client-id",
  "sub": "3f2b7c10-9a4e-4c53-8f1d-0b6e2a5d7c88",
  "name": "Maffin",
  "preferred_username": "Maffin",
  "email": "someone@example.com",
  "email_verified": true,
  "exp": 1788888888,
  "iat": 1788885288
}

name, preferred_username, email and email_verified appear only if you asked for the scope that carries them, and only if there is something to say - a profile with no display name set sends no name at all rather than an empty one. Everything else is always there.

The access token carries sub and nothing else about the person. If you want the current answer rather than the one frozen into the token, call /connect/userinfo with it.

About sub

sub is the account, not the login, and it never changes.

Somebody here can sign in with Steam or with an email address, and can add the second one later. Both are ways into the same profile, and all of them produce the same sub. It is a GUID generated once for the account and never reassigned - not their email address, not their SteamID, not a row id. File everything you know about a person under it.

The corollary: sub is the only identifier you should key on. An email address can be changed and a display name can be changed to somebody else's. Neither is an identity.

Lifetimes

Authorization code 5 minutes, single use
Access token 1 hour
Identity token 1 hour
Refresh token 90 days

Refresh tokens roll. Redeeming one issues a new one and retires the one you used, so keep the newest and throw the old one away. Presenting a refresh token that has already been redeemed gets you invalid_grant, and it is meant to: a second use is what a stolen token looks like. Do not work around it by retrying with the old one. A repeat within a few seconds is forgiven, so a request that timed out on the wire can safely be sent again.

An account that has been deleted stops producing tokens immediately, refresh token or not.

The person is asked once, per program, and their answer is remembered. They can withdraw it at any time from the Privacy tab of their account page. Withdrawing it revokes every token issued under it straight away, so plan for an access token that stops working before it expires - which you have to anyway.

Signing keys

Published at the key set address, each with a kid. Tokens name the key they were signed with in their header. Fetch the key set, cache it, and refetch when you see a kid you do not know: keys are rotated, and during a rotation the old key stays published for as long as tokens signed with it could still be alive.

Do not pin a key. Do not copy one into your configuration.

Errors

Standard OAuth errors, sent back to your redirect address as query parameters:

error What happened
access_denied They pressed Not now.
login_required You sent prompt=none and nobody was signed in here.
consent_required You sent prompt=none and they have not agreed to this yet.
invalid_grant The code or refresh token is spent, expired, or was not yours.

Running it

The provider is off unless a database is configured, because all of it - codes, consent, refresh tokens - is stored. Its signing keys live in Oidc-Keys/ beside the site and are generated on first run; the deploy is told not to delete that folder, in the same way and for the same reason as DataProtection-Keys/. Losing it does not lose accounts, but it does invalidate every token in the wild at once.

To rotate the signing key, call OidcKeys.Rotate() and restart. The new key signs from then on, the old one stays published and keeps verifying what it signed, and it is retired by deleting its file once nothing signed with it can still be alive - which is the refresh token lifetime above.

The plain file: /docs/accounts/sign-in.md