Skip to content

Access: sign-in, recovery, invitation and secrets ​

What the framework guarantees about getting in, staying in, and getting back in.

The policy lives in two files ​

ddcore.json holds what the site decided, because staging has to lock an account out exactly as production does or the rehearsal proves nothing:

jsonc
"auth": {
  "sessionDays": 30,          // session lifetime; the cookie follows it
  "apiKeyDays": 0,            // 0 = an API key never expires
  "minPasswordLength": 8,
  "maxLoginAttempts": 5,      // failures before the lockout
  "lockoutMinutes": 15,
  "resetMinutes": 60,         // how long a recovery link is good for
  "inviteHours": 72,
  "secureCookie": null,       // null = decide per request
  "selfServiceApiKeys": true,
  "passwordLogin": true       // false = single sign-on only (Admin keeps a password)
}

.env holds where the site is running — the public URL a link is built from, whether a proxy is trusted, and everything about mail. See .env.example; ddcore doctor reports which transport is live and warns when no public URL is set.

An invalid value is refused at load. A session that lasts zero days or a lockout after zero attempts is a policy nobody meant to write.

Signing in ​

POST /api/login with {usr, pwd}. On success a sid cookie: HttpOnly, SameSite=Lax, Max-Age from sessionDays, and Secure when the request arrived over TLS or the site URL is https.

Failures are counted in ddcore_login_attempt and answered with 429 TooManyRequestsError, a Retry-After header and error.extra.retryAfter. Three properties are load-bearing:

  • The throttle runs before the user lookup and before any hashing. Argon2 costs 64 MiB a call, so an unthrottled login is a memory-exhaustion vector needing no credentials.
  • The counter is keyed on what was typed, not the account it resolved to, and an unknown address is still checked against a decoy hash. Otherwise the fast, never-locking answer for an address nobody owns tells an attacker which addresses exist — and the lockout becomes the oracle it was meant to close.
  • The correct password is not a way out of a lockout.

Argon2 runs at most max(2, GOMAXPROCS) computations at once per process; further sign-ins queue for a slot instead of allocating another 64 MiB. An API key's secret goes through Argon2 once while its key row is cached (60 s): after that a matching secret is recognised by a per-process HMAC digest and skips the hash, so a key costs a hash a minute, not one per request; concurrent checks of the same secret share one hash. Revoking or disabling the key or its user still drops that cache entry, and a wrong secret is always hashed and counted towards the key's failure brake.

ddcore user unlock <email> lifts one early. An account can be locked under two spellings (its name and its e-mail), and the command clears both.

trustProxy decides whether X-Forwarded-For is believed. Leave it off unless a proxy you control sets it: otherwise any client picks the address we hold responsible, and the address throttle becomes a way to lock out a stranger.

A notice on the sign-in screen ​

A deployment can put a notice above the form and offer a demo account, with a button that fills the form in. Set it in ddcore.json or, per deployment, in the environment, which wins field by field:

json
{ "login": { "notice": "Public demo — data resets every 6 hours.", "demoUser": "visitor@example.com", "demoPassword": "demo-visitor" } }
env
DDCORE_LOGIN_NOTICE=Public demo — data resets every 6 hours.\nFeel free to break things.
DDCORE_LOGIN_DEMO_USER=visitor@example.com
DDCORE_LOGIN_DEMO_PASSWORD=demo-visitor
  • All three go out in the public /api/boot (site.login) to anyone who opens the site. They are for a shared demo account or a maintenance note, never a real account's password. Give the demo user a role that cannot administer the site.
  • The notice is plain text, shown as written, with its line breaks; \n in the variable is one. It is the operator's sentence, not a catalogue key, so it is not translated.
  • The demo account is offered only when both user and password are set.

Single sign-on (OpenID Connect) ​

Google, a self-hosted PocketID, or any other OpenID Connect provider. It is one generic client: the authorization code flow with PKCE, discovery from the issuer, and the id_token verified for signature, issuer, audience, expiry and nonce. Providers differ only in their variables.

bash
DDCORE_URL=https://erp.example.com             # required: the callback is built from it
DDCORE_OIDC_PROVIDERS=google,pocketid

DDCORE_OIDC_GOOGLE_CLIENT_ID=...               # issuer defaults to https://accounts.google.com
DDCORE_OIDC_GOOGLE_CLIENT_SECRET=...
DDCORE_OIDC_GOOGLE_ALLOWED_DOMAINS=example.com # optional

DDCORE_OIDC_POCKETID_ISSUER=https://id.example.com
DDCORE_OIDC_POCKETID_CLIENT_ID=...
DDCORE_OIDC_POCKETID_CLIENT_SECRET=...
# optional for any provider: _LABEL (the button), _SCOPES (default "openid email profile")

A provider id starts with a letter and goes on in lowercase letters, digits and _, up to 32 characters; the list is lowercased as it is read and a repeated id is refused. Any id other than google needs _ISSUER, which must be an http(s) URL and loses a trailing /. A provider missing its client credentials, or configured without DDCORE_URL, is refused at load. _SCOPES is separated by commas or spaces and always gains openid; _ALLOWED_DOMAINS is comma-separated, matched case-insensitively, and a leading @ is dropped; _LABEL defaults to Google, PocketID or the id itself.

Registering the client. The redirect URI is <DDCORE_URL>/api/auth/oidc/<id>/callback. ddcore doctor --json carries it per provider in sso.providers[] together with a five-second discovery probe — a provider that does not answer is a warning, never critical; the text report only names the configured ids and whether password sign-in is on. Discovery is fetched once per provider and kept, with a ten-second timeout on every call out; a failure is not kept, so a provider that was down is tried again on the next sign-in rather than until a restart.

  • Google: Google Cloud Console → APIs & Services → Credentials → OAuth client ID, type Web application, with that URI under Authorized redirect URIs.
  • PocketID: OIDC Clients → Add, with that URI as the callback URL. It is a confidential client, so leave Public client off; PKCE is always sent.

Nobody gets an account this way. The provider proves who someone is, and the site still decides whether they may use it:

  1. The email claim must be present and email_verified must be true.
  2. The address must be in _ALLOWED_DOMAINS, when that list is set — checked on every sign-in, not only the first, so narrowing the list takes effect at once.
  3. The first sign-in links the provider's sub to the enabled User whose e-mail is that address, or failing that whose name is, compared without case. Guest is never matched; Admin is. Later sign-ins resolve by sub, so an address changed at the provider still lands on the same account; a linked User since disabled is refused with disabled, and a link left pointing at a deleted User falls back to matching the address again. The link is kept in ddcore_user_identity; deleting or renaming the User carries it along.

Invite people first (ddcore user invite, or Save and invite on the desk's User form). They can then sign in through the provider without ever accepting the invitation.

The flow.

  • GET /api/auth/oidc/<id>/start?redirect=/app/... stores a single-use state — hashed, with a nonce and a PKCE verifier — in ddcore_oidc_state for ten minutes, binds it to the browser with an HttpOnly ddcore_oidc_state cookie (path /api/auth/oidc/, SameSite=Lax, the same ten minutes), and redirects to the provider. The route takes no session and writes a row per request; the sweep below clears the expired ones.
  • GET /api/auth/oidc/<id>/callback requires the state in the query string and in the cookie to match, spends it, exchanges the code, and sets the same sid cookie a password sign-in sets.
  • Only a path on this site is accepted as redirect: //host, /\host and absolute URLs fall back to /app.

A failure of the flow redirects to /login?sso_error=<code>, where the desk shows a translated message. The codes are state, provider, unverified_email, no_account, disabled, domain, throttled and server. A cancelled sign-in arrives as provider; a failure on our own side — a database that went away, a bug — is server, so the logs do not send anyone to the identity provider for it. The desk shows the same general sentence for both. An id that is not configured is the exception: start answers 404 DoesNotExistError as JSON, since nothing is under way to send anywhere.

Callback failures are throttled per client address, maxLoginAttempts × 5 inside the lockout window — the looser limit, because an office shares one address. A completed sign-in clears both that account's password-login failures and the address's, as a password sign-in does.

Audit. A callback writes account.login_sso, Allowed or Denied, with the provider and the reason in the detail. Two paths never reach it: a throttled callback, and the provider's own ?error= return, which is answered before the callback runs. A first link also writes account.identity_link.

Password sign-in off. Set "passwordLogin": false and POST /api/login refuses everyone but Admin, and forgot-password sends nothing to anyone else. The refusal is answered only once the password has verified, for the reason a disabled account is. The desk hides the form behind an Admin sign-in link. Admin keeps a password so that an outage at the provider is not also an outage of the site's administration. Setting false with no provider configured is refused at load.

/api/boot exposes site.login.password and site.login.providers ({id, label} only) to the sign-in screen.

Groups as roles ​

A provider's groups can grant roles. The map is site policy, so it lives in ddcore.json, keyed by the provider id:

jsonc
"auth": {
  "sso": {
    "pocketid": {
      "groupsClaim": "groups",   // the default
      "groupRoles": { "erp-admins": ["System Manager"], "erp-sales": ["Sales User"] }
    }
  }
}
  • Managed roles. A role named anywhere in groupRoles is managed. Each sign-in sets the managed roles to exactly what the groups grant. Every other role is left as it was, so roles given by hand still work.
  • How the change is saved. It goes through the User document, so it leaves role.assign/role.revoke events and drops the roles cache like an edit in the desk.
  • The claim decides what counts as "no groups". A missing claim changes nothing and logs a warning, because the provider may simply not have been asked. An empty list does take every managed role away.
  • Other cases. A group mapped to a role that does not exist is skipped with a warning, and Admin is never touched.
  • Where the groups come from. With PocketID provisioning on, ddcore creates a mapped group PocketID does not have yet the first time a User needs it. Without provisioning, create the groups at the provider yourself.
  • Scope. With a map configured, the groups scope is added to the provider's scopes.
  • Refused at load: a policy for a provider not in DDCORE_OIDC_PROVIDERS, a group mapped to no role, and Admin, Guest or All as a mapped role.

PocketID provisioning ​

Set a PocketID admin API key and ddcore becomes where accounts are made:

bash
DDCORE_OIDC_POCKETID_API_KEY=...   # PocketID → Settings → API Keys, created by an admin
DDCORE_OIDC_POCKETID_KIND=pocketid # the default for the id "pocketid"; set it for any other id

_API_KEY is accepted only on a provider of kind pocketid, and only one provider may carry it. The admin API is reached at the issuer URL with the X-API-Key header and a ten-second timeout.

  • Invitation. Inviting a System User (users.invite, Save and invite, or ddcore user invite) does the following:

    1. It finds the PocketID account with that address, or creates one with emailVerified set. A disabled account is re-enabled.
    2. It sets the account's mapped groups from the User's roles. A mapped group PocketID does not have yet is created there, named as in groupRoles, with its roles joined by commas as the display name.
    3. It mails core.invite_sso, which carries PocketID's one-time link (<issuer>/lc/<token>, valid inviteHours, at most 31 days) to register a passkey, and then the site's sign-in address.

    No password token is issued. The provider is called inside the transaction: a refusal fails the invitation with PocketID's reason. If a group cannot be created, the message names the group so it can be created by hand in PocketID (Administration → User Groups). An account left behind by a rolled-back invitation is found and reused by the next one. Resend invitation issues a new link the same way. A Website User is still invited with a password, because the portals sign in with one.

  • Roles → groups. With provisioning on, a save of a User that changes a managed role queues core.services.idp.sync (queue idp, exponential backoff, 5 attempts). The job sets the account's mapped groups, creating any that PocketID does not have yet, and leaves its other groups alone.

    • A group is due when the User holds every role it maps to.
    • A save made by a sign-in is not pushed back.
    • A User with no PocketID account is nothing to do.

    Together with the sign-in, this makes the last change win on either side.

  • Disable, enable, delete. Nothing is disabled at PocketID on its own: the account may open other applications too. When a User is disabled, enabled or deleted from its form, the desk asks whether to do the same in PocketID. On a yes it calls core.services.users.setProviderDisabled({user?, email?, disabled}) (System Manager). That call finds the account by the linked subject or by address, sends the whole user back with disabled changed (PocketID's PUT replaces the user), and records account.idp_disable or account.idp_enable. A deletion only disables at PocketID; it never deletes there. Deleting from the list view, the API or the CLI does not ask.

  • Doctor. ddcore doctor probes the API key and warns about mapped roles missing on the site. It lists the mapped groups PocketID does not have yet (groupsToCreate in --json) as created on first use: that list is not a warning, and the doctor never creates anything itself.

Sign-in through an app (credential providers) ​

Some people sign in with the username and password of another system — an ERP, a directory — rather than with an e-mail and a password kept here. An app declares a credential provider, and the sign-in screen gets a tab for it where the person picks a tenant, types that username and that password, and lands in the desk with the same sid cookie a password sign-in sets (#115).

ts
// shared/erp/ddcore.app.ts
export default defineApp({
  name: "erp",
  auth: {
    providers: {
      erp: {
        label: "ERP",                       // the tab; an English catalogue key
        userField: "erp_username",          // a Data field the app adds to User
        enabled() { return isConfigured(); },
        verify({ username, password }) {    // synchronous, in the chosen tenant
          const r = ddcore.http.post(url() + "/login", { username, password });
          if (r.status >= 500) throw new Error("ERP unavailable");
          if (r.status !== 200) return { ok: false, reason: "HTTP " + r.status };
          return { ok: true, subject: r.json().login, data: { token: r.json().token } };
        },
        afterSignIn({ user, data }) {       // optional: keep what verify returned
          ddcore.vault.set("erp:session:" + user, JSON.stringify(data));
        },
      },
    },
  },
});

The division of labour is the point: the app only answers "is this the password of this username, there". ddcore does everything that makes that a sign-in:

  • Who signs in. The account is the User whose userField holds the username — the subject verify returned, else what was typed, compared without case — in the tenant the person picked. An administrator links people by filling that field in on the User form; nobody gets an account by signing in (no just-in-time provisioning). No such User: There is no account for this user here. More than one: refused, never guessed. Disabled, Admin or Guest: refused.
  • userField must be a stored Data field of User after extensions, added with extendDoctype("User", …). A unique field of User is unique across the site, not per tenant (a User's id is), so leave it off when the same username can exist in two tenants' systems and check within the tenant in a validate hook if you need to.
  • Where. On a site with tenancy the provider's tab asks for the organization — the tenant id, typed, never listed: the tenants of a site are its customers (#117). It then goes to /login/<provider>/<tenant>, a page that shows the organization's title and asks for the username and password; that address can be bookmarked or handed to a store's staff. The page reads GET /api/auth/credentials/<id>/tenants/<tenant> → {data: {id, title}, tenancy}, which answers only for an enabled tenant whose enabled() returns true (cached for a minute) and a 404 for any other — missing, disabled or opted out alike. Each miss counts against the client address (maxLoginAttempts × 5 in the lockout window, a counter of its own), so guessing ids is throttled like guessing passwords. GET /api/auth/credentials/<id>/tenants lists nothing any more; it answers {data: [], tenancy}. /api/boot carries site.login.tenancy next to the providers. Without tenancy there is no organization to ask for: the tab asks for the username and password straight away.
  • The hooks run as Admin with permissions ignored, inside the tenant: enabled(ctx) (omitted means every tenant) and verify(args, ctx), which is bounded to 20 seconds and should call the other system with ddcore.http. It returns { ok: true, subject?, data? } or { ok: false, reason? }; anything that is not exactly ok: true is a refusal. reason goes to the audit record only. data is handed to afterSignIn and nowhere else — never stored or logged by ddcore. Throwing means unavailable (503, Try again later), not a wrong password. afterSignIn({ user, username, subject, data }, ctx) runs in the transaction that opens the session, before it does; throwing there refuses the sign-in. Never log the password.
  • Two transactions. verify runs in one, the account lookup, afterSignIn and the session in another, so no transaction stays open while the other system answers. enabled() is asked again at sign-in: the list the browser was given proves nothing.
  • Throttle. Before verify is called, the same policy as a password sign-in: maxLoginAttempts failures per username in that tenant through that provider, and maxLoginAttempts × 5 per client address, the address counter shared with password sign-in so switching method does not reset it. A verify that throws counts against the address only, so an outage of the other system locks nobody out. Unlocking a user (users.unlockUser, ddcore user unlock) clears the provider's counter for the username in the user's userField. Without the throttle, ddcore would be an open relay for guessing passwords of the other system.
  • Audit. account.login_credentials, Allowed or Denied, in the tenant (in the platform space when the tenant does not exist), with provider, tenant, username, subject (when it differs) and, when denied, reason: invalid (with verify's reason), no_account, ambiguous, disabled, not_offered, tenant or unavailable. A throttled attempt writes nothing, as with single sign-on.
  • passwordLogin: false does not touch credential providers. The check that refuses false with no OIDC provider configured still applies.
  • The endpoint is POST /api/auth/credentials/<id>/login with {tenant, usr, pwd}, answered like /api/login. A tenant that does not exist or is disabled is answered like a wrong password. /api/boot lists the providers as site.login.credentials ({id, label}).
  • Load checks. An id outside ^[a-z][a-z0-9_]{0,31}$, one taken by another app or by an OIDC provider, an empty label, a missing verify or a userField that is not a stored Data field of User fail the load.

People without a mailbox. A User is still keyed by e-mail. Give someone who has none a placeholder under the reserved .invalid domain (RFC 2606), with the tenant in it so it stays unique across the site: joao@modaverao.invalid. Mail to such an address is skipped — sendMail to nobody else answers { delivery: "", skipped: true } instead of failing — forgot-password sends nothing, and inviting one is refused: add the user without an invitation.

Sessions ​

One TTL, read from the policy in both the SQL and the cookie. Sessions record ip and user_agent so a person can recognise their own devices.

Every path that sets a password ends the user's other sessions — the User form, ddcore user passwd, a recovery, and self-service. A recovery ends all of them, because the reason for a reset may be that the account is no longer theirs. Self-service spares the caller's own, so nobody is thrown out of the tab they just typed into.

A session is addressed from app code by an opaque handle, never by its sid. The sid is a bearer token: one XSS able to read a session list would otherwise hand over every device.

Impersonation ​

An administrator can work as another user to see the desk — or the portal, for a Website User — exactly as that user does: their workspaces, permissions, access scopes and data. The Impersonate button on the User form calls POST /api/impersonate {user} (ddcore.impersonate(user) from a desk script), and the page reloads as the user with a banner to stop, which calls POST /api/impersonate/stop.

  • Who. Admin, or a System Manager outside portal mode. Nobody can impersonate Admin, Guest, themselves or a disabled user, and there is no impersonation inside another. Under tenancy the spaces are the ones ddcore.runAs keeps: a tenant's System Manager reaches only that tenant's users (another tenant's answer as not existing), a platform System Manager reaches any tenant's, and the impersonated user always works in their own space, whatever tenant the session had entered.
  • Where it lives. On the administrator's own session (ddcore_session.data->>'impersonate'), never on an API key. No session of the user's is created, so their last login, their list of sessions and their ability to revoke are untouched.
  • What is written. Reads and writes are the user's: owner and modified_by name them. Every audit event and Version written meanwhile carries impersonated_by with the administrator; account.impersonate and account.impersonate_stop record the start and the end, and the access log adds impersonated_by to each request line. ctx.session.user in server code is the user.
  • What ends it. The banner, signing out, the user being disabled, deleted or given a new password (whatever ends their sessions ends every impersonation of them), and the administrator losing the role — checked on every request.
  • What it refuses. Nothing that would outlive it: creating or revoking the user's API keys, changing their password and revoking their sessions answer Not available while impersonating.

Recovery and invitation ​

Four public routes, all throttled, all exempt from the CSRF header check because there is no session yet to forge a request from:

RouteBody200
POST /api/auth/forgot-password{usr}{ok:true} always
POST /api/auth/token{token}{kind, user, fullName, expires}
POST /api/auth/reset-password{token, password}{ok:true}
POST /api/auth/accept-invite{token, password, fullName?}{ok:true}

forgot-password answers identically for an unknown address, a real one and a disabled account — status, body and timing. Timing is free: there is no Argon2 on that path, and the message goes through the job queue rather than out a socket.

Tokens are 192 bits of crypto/rand, stored as their SHA-256, single-use, and spent in one statement so two submissions of the same link cannot both set a password. A kind is checked, so a pending invitation is not also a way to reset somebody's password. Completing a recovery does not sign the person in: one way to get a session is enough to reason about.

An invited account is created with no password, which needs no flag — an empty hash already refuses every sign-in until one is set.

Mail ​

DDCORE_MAIL_TRANSPORT is log (the default: the link goes to the log and is handed back to whoever asked), smtp, or method. method names an app function by dotted path in DDCORE_MAIL_METHOD — the same way scheduler and ddcore.enqueue already name app code.

The invitation and the recovery message are two ordinary templates, core/mail/invite.mail.ts and core/mail/reset.mail.ts, sent the way an app sends anything — see mail. Both declare sensitive, because their one argument is a link that can set somebody's password: the Email Delivery record says who it went to and whether it arrived, and holds no part of the link.

Messages go through ddcore_job, so retries are durable, and because the delivery record and the job are both written on the request's transaction a message is only queued if the request commits. The SMTP sender refuses to authenticate in the clear to anything but loopback.

Self-service (core/services/) ​

A user has no write on their own User record, and granting it is not an option: roles is a child table on User, so write would be self-promotion to System Manager. These services exist to offer the narrow thing instead, and each checks ddcore.session.user before touching anything:

  • profile.getMyProfile · profile.updateMyProfile({fullName?, language?, emailNotifications?: {assignment?, share?, due?}}) · profile.changeMyPassword({current, password})
  • sessions.listMySessions · sessions.revokeMySession({id}) · sessions.revokeMyOtherSessions
  • api_keys.listMyAPIKeys · api_keys.createMyAPIKey({label, days?}) · api_keys.revokeMyAPIKey({id})

updateMyProfile enumerates its fields and never spreads args — that enumeration is the security of the function, since the write underneath skips the permission check. emailNotifications holds one boolean per kind of email the core sends for its own notifications (true = send); a key left out is not changed. See notifications, "Email for the core's own notifications".

getMyProfile answers hasPassword, and never the hash. The profile page shows its Password card only when it is true: changeMyPassword asks for the current password, and someone who signs in only through a credential provider or SSO has none to give. An app that signs people in through its own provider adds its own card to the page with defineProfileSection (see form-api).

System Manager only: users.invite, users.resendInvite, users.sendPasswordReset, users.accountStatus, users.revokeUserSessions, users.unlockUser.

Saving a User — the desk's form, insert_doc, ddcore user add, an import — only writes the row and mails nobody. The invitation goes out from Save and invite on a new User form, which calls users.invite with what was typed. A saved, enabled User offers Resend invitation while it has no password and Send password reset once it has one; users.accountStatus answers { user, hasPassword } so the form can tell which, without the hash leaving the server. When mail is not delivered (the log transport), the link comes back and the form shows it to be passed on by hand.

The first two are wrappers over ddcore.users.invite and ddcore.users.resendInvite, which app code calls directly to invite its own portal users. Without System Manager, that call can only create a Website User and never give a privileged role. A Website User (user_type) reaches the portals and nothing else, signs in to /portal, and is refused every other /api route. The profile, password, session and language methods above are open to them (whitelisted(fn, { portal: true })); the API key methods are not. See portal.

Passwords ​

The policy is applied where hashing happens — the hashPassword host op and Engine.SetPassword — rather than at each of the five call sites, so app code nobody has written yet meets it too. Minimum length (in runes), not blank, not the account name, and a cap so an unauthenticated caller cannot choose how much Argon2 the server does. No complexity ruleset: those produce Password1! and a sticky note.

Secrets ​

An integration credential is read from the environment, never from a column:

ts
const key = ddcore.secret("stripe_key");   // DDCORE_SECRET_STRIPE_KEY

A secret in the database is a secret in every backup, replica, export and Version diff; one in the environment is in none of them because it was never written down, and rotating it is a redeploy rather than a migration. The prefix is the boundary — an app reads its own secrets and nothing else the process was started with.

A setting that varies per deployment but is not a credential — a switch such as "register our webhook at boot", a provider's region — is read with ddcore.env("register_webhook") from DDCORE_APP_REGISTER_WEBHOOK, so it does not have to pose as a secret. ddcore doctor prints those with their values.

A Password field is for a secret a person types. It is still plain text at rest; what the framework guarantees is that it does not leave — blanked on every read through the API, absent from Version, absent from an export.

Housekeeping ​

core.services.auth.sweep deletes expired sessions, old tokens, old attempts and expired single sign-on states, hourly, where the site enables the scheduler. It is hygiene and never correctness: every read path filters on expiry itself, so if it never ran nothing would become valid again — the tables would only grow.

Not here yet ​

A real CSRF token (the check is header-presence only), MFA, LDAP and just-in-time provisioning of a User on first sign-in (SEC-05, in the demand-driven backlog; an app's credential provider covers signing in against another system, not creating the account), a User without an e-mail (a placeholder .invalid address stands in), choosing the tenant by hostname, provisioning at providers other than PocketID, provider-initiated (single) logout, a screen to see or remove one's linked identities (unlinking is a DELETE in ddcore_user_identity), e-mail verification on a changed address, and an admin UI for unlocking or listing another user's sessions.

Sharp edges of the single sign-on that is here: provider configuration is read at boot, so a change to DDCORE_OIDC_* or auth.sso needs a restart and not a reload; with password sign-in off, an invitation or an admin's reset link still sets a password nobody but Admin can use; and email_verified is taken at the provider's word, so a provider is trusted for every address it says it checked.

MIT Licensed · ddcore (Data Driven Core) · Development documentation (main)