screentinker/docs/sso-setup.md
ScreenTinker 4bc0d433b0 Document how to actually set single sign-on up
The README described what SSO is and which variables exist. It did not say where
to click, which of the several plausible values to use, or what any failure
means -- so configuring it meant reading source, and every wrong turn produced an
error code with no stated cause.

docs/sso-setup.md walks both audiences: the operator wiring up Google or
Microsoft for the instance, and an organization admin bringing their own
provider and proving a domain. Written from doing it end to end against real
Google and Entra tenants, so the traps in it are the ones actually hit rather
than the ones imagined:

  - MICROSOFT_TENANT_ID is the directory that AUTHENTICATES the user, not the
    one the app registration lives in. For personal accounts those differ, and
    using the visible Directory (tenant) ID fails every login with an error that
    points at the tenant rather than at the setting;
  - Web platform, not SPA -- a SPA registration is refused at the token endpoint
    because the exchange is server-side and sends no Origin;
  - a Web registration is a confidential client, so the secret is not optional;
  - Entra needs the `email` optional claim added, or the token arrives with no
    address and fails as no_email;
  - Google's redirect URI matches byte for byte, and Testing publishing status
    silently limits sign-in to listed test users.

Every error code the server can emit is in a table with its usual cause. Each
one was checked against the source rather than remembered, as were the variable
names and the DNS record format.

Also covers what the account rules mean in practice: linking deletes the
password, unlinking sets a new one in the same step, SSO-only clears passwords
irreversibly, and linking the platform admin makes that provider the only way
in.
2026-08-12 15:11:13 -05:00

13 KiB

Single sign-on — setup guide

How to turn on SSO, for the two people who need it: the operator running the server, and an organization admin bringing their company's own identity provider.

Everything below is OpenID Connect. One flow — Authorization Code with PKCE, completed server-side — so the browser never talks to the provider directly and there is no SDK to load.


Contents


Which kind of SSO do you want?

There are two, and they are configured in completely different places.

Instance-wide Per-organization
Configured by the operator, in environment variables an org owner/admin, in Settings → Single sign-on
Restart needed yes no
Who sees the button everyone, on the login page only people at that organization's verified domains
Typical use "Sign in with Google" for anyone a customer wiring up their own Entra/Okta tenant

An organization's provider overrides the instance's for its own verified domains, and never appears publicly — the login page reveals it only after someone enters an address at one of those domains, so a guessed domain cannot confirm who your customers are.


Operator: Google

Google is the simplest: one fixed issuer, and it reports whether an address is verified.

  1. console.cloud.google.com → create a project (a dedicated one — if you reuse an auto-created AI Studio project and later tidy those up, you take sign-in down with it).
  2. Google Auth Platform (formerly "OAuth consent screen"):
    • App name — users see this on the consent screen
    • Audience: External
    • Support and contact email
    • Scopes: nothing to add. openid, email and profile are implicit and non-sensitive, so no Google verification review is required.
  3. Clients → Create client → Web application
    • Authorized redirect URI, exactly:
      https://your-domain.example/api/auth/oidc/google/callback
      
    • Leave Authorized JavaScript origins empty — the exchange is server-side.
  4. Audience → Test users: while publishing status is Testing, only listed accounts can sign in. Add yourself, or Publish app (safe here, given the scopes).
  5. Set the environment:
    GOOGLE_CLIENT_ID=…apps.googleusercontent.com
    GOOGLE_CLIENT_SECRET=# a Web application client needs one
    

Google matches redirect URIs byte for byte. No trailing slash, https not http.


Operator: Microsoft / Entra ID

Microsoft needs one decision up front: whose accounts are signing in? That decides both the app registration and, crucially, the tenant ID you configure.

The rule that catches everyone

MICROSOFT_TENANT_ID is not "where the app is registered". It is the directory that authenticates the user, because it is what the ID token's iss will say. Those are different things whenever the two differ — most obviously for personal accounts.

Who signs in Supported account types MICROSOFT_TENANT_ID
Personal Microsoft accounts (outlook.com, hotmail, …) Personal Microsoft account users 9188040d-6c67-4c5b-b112-36a304b66dad (Microsoft's consumer directory)
Your own staff Single tenant your Directory (tenant) ID

⚠️ common, organizations and consumers are refused, deliberately. Two reasons that point the same way. They cannot work: Microsoft's multi-tenant metadata advertises the issuer as the literal template https://login.microsoftonline.com/{tenantid}/v2.0, so iss can never match. And the obvious workaround is dangerous — accepting that template means accepting tokens from every Azure tenant, which is nOAuth: any tenant admin can set an arbitrary, unverified email on one of their own users and be issued a session as that address. Safe multi-tenant support needs per-tenant pinning (allowlist tid, key accounts on oid+tid rather than email) and is not implemented. Setting one of these disables Microsoft sign-in with a warning at boot rather than failing quietly.

Steps

  1. portal.azure.com → Entra ID → App registrations → New registration
    • Name — users see this on the consent screen
    • Supported account types — per the table above
    • Redirect URI: platform Web, value
      https://your-domain.example/api/auth/oidc/microsoft/callback
      
      ⚠️ Web, not SPA. A SPA registration is rejected at the token endpoint, because this exchange runs server-side and sends no browser Origin.
  2. Certificates & secrets → New client secret → copy the Value (shown once, not the ID). A Web registration is a confidential client; the exchange fails without it.
  3. Token configuration → Add optional claim → ID → email. Without it the token can arrive with no address at all, which fails as no_email.
  4. Set the environment:
    MICROSOFT_CLIENT_ID=MICROSOFT_TENANT_ID=# see the table — NOT necessarily the directory the app lives in
    MICROSOFT_CLIENT_SECRET=

Entra never sends email_verified. ScreenTinker treats a tenant-pinned Microsoft provider as vouching for the address rather than demanding a claim Microsoft does not emit — safe because the operator chose that provider and it is pinned to one directory. An explicit email_verified: false is still refused.


Operator: any other provider

Okta, Auth0, Keycloak, Authentik, Zitadel — anything with a discovery document:

OIDC_PROVIDERS=okta,authentik              # comma-separated slugs
OIDC_OKTA_ISSUER=https://example.okta.com  # the base URL whose /.well-known/openid-configuration describes it
OIDC_OKTA_CLIENT_ID=OIDC_OKTA_CLIENT_SECRET=# optional — PKCE means a public client works
OIDC_OKTA_NAME=Okta                        # optional button label
OIDC_OKTA_SCOPES=openid email profile      # optional
OIDC_OKTA_ASSUME_EMAIL_VERIFIED=true       # only if it verifies addresses but omits the claim

Redirect URI is https://your-domain.example/api/auth/oidc/<slug>/callback.

Set APP_URL so the redirect URI is pinned to one origin. It must match your provider's registration exactly, and deriving it from the request Host would both break behind a second hostname and take its value from the caller.


Organization admin: bring your own provider

No environment variables, no restart, no operator involvement.

  1. Settings → Single sign-on → Add provider
    • Issuer — for Entra, https://login.microsoftonline.com/<your-tenant-guid>/v2.0
    • Client ID and Client secret from your own app registration
    • Email domains you intend to claim
  2. Copy the redirect URI shown in Settings and register it with your provider. It carries a generated slug, so two customers can neither collide on nor guess each other's.
  3. Verify each domain. Publish the TXT record shown:
    _screentinker-verify.<your-domain>   TXT   st-verify=<token>
    
    Then press Verify. An unverified claim lapses after 8 hours and releases the domain.

Your provider may only assert addresses at domains you have proved you control. A domain can be claimed by one organization only; a second claim is refused.

Proof by CNAME is not accepted — it would need a wildcard zone we do not operate, and would turn a subdomain takeover into an apex takeover.

Once a domain is verified, your provider is trusted to assert addresses in it even if it omits email_verified (as Entra does) — the DNS proof stands in for the claim. A provider that has verified nothing assumes nothing.


Requiring SSO for your organization

Settings → Single sign-on → Require single sign-on. Then, for anyone at your verified domains:

  • passwords are refused
  • other providers are refused, including the instance's own Google/Microsoft — otherwise "requires SSO" would just be renaming the bypass

⚠️ Enabling this clears the passwords of members at your verified domains. That is not reversible without a reset.

Turning it off requires a platform administrator to approve the request, so one compromised org admin cannot quietly reopen password login. Plan for that turnaround before you enable it.


Linking an existing account

Signing in with a provider never takes over an account that already has a password — otherwise anyone who could get a provider to assert your address would inherit your account. Link it deliberately instead:

Settings → Sign-in method → Link <provider>

  • An account has one credential. Linking deletes the password; afterwards you sign in with the provider only.
  • Unlink asks for a new password and applies both changes together, so the account is never left without a way in.
  • The provider account must use the same email address as the ScreenTinker account.
  • Only the providers this server offers can be linked — an organization's own provider cannot attach itself to an account.

⚠️ If you link the platform administrator account, that provider becomes the only way in. Should it break, recovery is scripts/reset-admin.js on the server, not the login page.


What users see at sign-in

The login page asks for an email address first and shows the password box only after you continue. That is what lets it check whether the address belongs to an organization with its own provider before offering a credential — so someone whose company requires SSO is shown that, rather than a password box that was going to be refused. Correcting the address takes you back a step.

The instance's own providers are shown throughout.


Troubleshooting

Errors appear as a message on the login page (or Settings, when linking). The exact code is in the URL as sso_error=…, and the server logs a matching [oidc] line with the underlying reason.

Code What it means Usual cause
unknown_provider No such provider on this server Slug typo; or MICROSOFT_TENANT_ID is multi-tenant, so Microsoft was disabled at boot — check the [sso] warning
provider_unavailable Discovery or the token exchange failed Wrong issuer URL; no outbound network; missing client secret on a confidential client
provider_refused The provider itself said no Consent declined; conditional-access policy; account not on the Google test-user list
expired The round trip took too long Left the tab open; started over in another tab
bad_state / no_code The response did not match the request Started in one browser and returned in another; a redirect URI that does not match the registration
verification_failed The ID token did not verify Wrong tenant — the log prints the iss actually seen; clock skew; wrong client ID
no_email The token carried no address Entra: add the email optional claim under Token configuration
email_unverified The provider would not vouch for the address The provider sent email_verified: false; or it omits the claim and is not eligible to assume (an org provider with no verified domain)
account_exists_local That address already has a password Sign in with the password, then Settings → Sign-in method → Link
account_exists_other_provider The account belongs to a different provider Unlink first, or sign in with the provider that owns it
subject_mismatch Same address, different provider subject The address was reassigned. Deliberate: it stops a recycled mailbox inheriting an account
domain_not_allowed The provider asserted a domain it has not verified Verify the domain, or check which address the provider is actually sending
sso_required The organization requires its own provider Use the organization's button, not the password box or an instance provider
registration_disabled New accounts are turned off The address has no account and self-registration is disabled
link_email_mismatch The provider account has a different address Sign in to the provider with the same address as the account
link_already_used That provider identity is linked elsewhere Unlink it from the other account first

Checks worth doing first

# What the server thinks is configured (public endpoint)
curl -s https://your-domain.example/api/auth/config

# Does the start URL carry the right issuer and redirect?
curl -s -o /dev/null -D - https://your-domain.example/api/auth/oidc/google/start | grep -i location

# Boot warnings, and every login outcome
docker logs <container> 2>&1 | grep -E '\[sso\]|\[oidc\]'

/api/auth/config reporting microsoftEnabled: false while MICROSOFT_CLIENT_ID is set almost always means the tenant ID was rejected — look for the [sso] line at boot.