Authentication and identity¶
This page covers how one command gives you a secure user login, why each default is set, how user identity differs from entity identity, and the session lifecycle. It is the practical side of security.
Secure defaults, with no insecure state to get stuck in¶
A login can work long before it is safe. Many systems ship a working but insecure login (a token in local storage, a secret in the browser bundle, no CSRF defense, a cookie without the right flags) and never fix it, because the demo already worked.
In SynQt the default is the secure path, and no setting works while being insecure. The command that adds auth produces a hardened configuration. You can widen it, but you never have to remember to add protections: they are on from the first run. Someone always forgets an optional safety control, so the controls that matter are on by default, visible, and must be justified when removed.
The defaults synqt add auth sets:
- Authorization Code flow with PKCE (on by default in Qt since 6.8), run entirely on the web edge. The browser never holds a client secret.
- A random state on every authorization request (CSRF defense). The framework generates it with a cryptographic RNG and verifies it on the callback. Qt 6.12 generates one when none is set, but the framework sets its own, because the state is also the key for the pending login: before the browser leaves, the framework stores the PKCE verifier, the OIDC nonce and the browser binding under it, and on the callback it looks them up by the state. A value the framework learned only after building the request could not be that key.
- The session credential in an httpOnly, Secure, SameSite cookie. httpOnly hides it from page script, so a cross site scripting bug cannot steal it. Secure keeps it on TLS. SameSite blunts cross site request forgery.
- Access, refresh and ID tokens stay on the edge, stored with the session, never sent to the browser and never logged.
- ID token signatures verified against the provider's JWKS when ID tokens supply the
identity, because Qt does not verify ID tokens. Qt has no JWT or JWKS API, so the
framework verifies with the
jwt-cpplibrary (MIT, v0.7.1 or newer), and fetches and caches the JWKS with QNetworkAccessManager. All of the cryptography comes from the library. The key must be RSA, and a key that states auseor analgmust statesigandRS256. The token must name this client among its audiences, and a token with several audiences, or with anazpclaim, must name this client inazp. - A nonce on every OpenID Connect authorization request, checked against the
nonceclaim in the returned ID token. It binds that token to this login, so a token replayed from elsewhere fails. It is separate from the state: the state protects the callback, the nonce protects the token. Exactly one nonce is sent. Qt adds its own whenever the scope containsopenid, so the framework gives Qt its random value instead of adding a second parameter. A request with twononceparameters is malformed (RFC 6749 section 3.1), and a strict provider refuses the login. - Session expiry and rotation: a bounded lifetime, and a new session id when privilege changes, which limits what a stolen session is worth and prevents session fixation.
- Login rate limiting, plus the same origin and upgrade checks as the rest of the system.
The command produces all of these, with nothing to wire by hand.
Adding auth: one command¶
This:
- Writes the
identitysection, and an entry underidentity.providersfor the named provider with the secure defaults above (see thesynqt.yamlschema). - Adds the provider's
client_secretas anenv:reference, and a.env.exampleentry that documents the required secret without setting it. - Scaffolds the login and callback routes on the web edge.
- Scaffolds an identity mapping hook (
web/edge/identity/map.qml) that returns the default scope, ready for you to map specific identities to higher scopes. - Prints exactly what you must do next (register the OAuth app with the provider, set
the redirect URL to the edge callback, put the secret in the edge's
.env), and nothing else.
Two providers have templates by name, github and google. Any other name becomes a
generic OpenID Connect entry; you fill in the issuer and endpoints.
To require login for the whole app instead of allowing anonymous reading:
This sets identity.required: true, so the edge refuses the connection of a browser that
has not signed in, before any connect point exists. Until then the client stays
reconnecting with Session.isAuthenticated false, so show a sign-in button that calls
Session.login().
The development sign-in¶
This section and the three after it describe the development helpers. Developing locally compares them and says when to use which.
Registering an OAuth app is a chore on a project's first afternoon, and until you do,
nothing scope-gated is reachable. So synqt dev can run its own provider:
This writes one block:
identity:
dev_stub:
users:
- { sub: dev, login: dev, name: Developer, email: dev@localhost }
- { sub: mod, login: mod, name: Moderator, email: moderator@localhost }
That is the whole configuration. The framework derives the provider entry, because every
field follows from where the server runs: the endpoints are its own routes on
127.0.0.1, the issuer is the address it answers on, and the client id is a constant.
You may want to change port; synqt check refuses a port another entity already uses.
Only the provider is fake. The random state, the PKCE challenge, the code exchange, the ID token and its signature check against the JWKS, the mapping hook, the session and its httpOnly cookie are the same as with a real provider. A development sign-in that skipped part of the flow would test something other than what ships.
That is also why a development user is an identity, not a scope. Sign in as one of the
people above and you get whatever your own map.qml returns for them; to reach
moderator, add someone your hook maps there. With more than one person configured, the
sign-in asks which one you are; with exactly one, it does not ask.
Independent gates keep it out of anything that ships:
- A release build does not compile the sources.
src/edge/CMakeLists.txtnames them only underSYNQT_DEV_TOOLS, whichsynqt devsets andsynqt buildnever does, and the header refuses to be included by any other build. See Development code cannot ship. - The server starts only under
--dev.synqt devpasses it;synqt build,synqt serve, a systemd unit and a container never do. StubIdentityServerrequires an explicit acknowledgement to be constructed, which nobody writes by accident, so no other code can reach it by mistake.- The runtime refuses the provider entry unless the same flag is set. An edge that somehow contained the server would still sign nobody in; the login route answers 403.
synqt check --release reports a project that has one and notes that it is inert, without
refusing the build, because leaving the block in place is normal. The development sign-in
and the real provider live side by side, and the way the edge was started decides which
one a visitor gets.
Skipping the flow: the scope picker¶
The development sign-in tests the flow. Sometimes you want the opposite: skip the flow and be a moderator for thirty seconds to see what the page looks like.
This replaces every sign-in in the project with one page at /synqt/dev/identity that
lists the scopes in scopes.order. Click one and you get a session at that scope, with a
synthesized identity whose sub is synqt-dev:<scope>:<epoch-ms>, so it never collides
with anything a real provider issues.
It skips OAuth entirely (no PKCE, code exchange, ID token or JWKS) and does not consult the
mapping hook, since its purpose is to pick the scope directly. So identity.dev_stub
stays beside it: the stub tests the flow, and the picker skips it. Use the stub to test
signing in, and the picker to see what a scope can see.
The chosen scope is still checked against scopes.order, so editing the form to post a
larger number does not create a scope the project never declared.
Being somebody in particular: .dev-identities¶
Picking a scope covers "let me be an admin for a minute", but not "let me be Alice again",
which you need when working on anything tied to a person. A .dev-identities file at the
project root lists those people:
The picker offers each person beside the scopes, and clicking one signs you in as them.
sub is synqt-dev:<email>, stable across restarts, so a project that stores rows by
sub sees the same person on the next run.
Unlike scope mode, this mode consults the mapping hook, because the point of naming a person is to see what your own rule makes of them. The picker lists the scope from the file, and the session gets the hook's answer. When they differ, the page shows both. When the hook refuses the identity, the picker refuses it too, since a development sign-in that granted what your rule denies would reach a state the application never can. A project with no mapping hook has nothing to ask, and the page says so, so the file's scope does not look like the hook's answer.
synqt dev reads the file, not the edge. synqt dev already parses YAML and knows the
project's declared scopes, so the edge receives a checked list. The picker drops an entry
with an undeclared scope or a missing field, reports it on its page and in the terminal,
and keeps serving. A typo in the file costs you that entry, not the sign-in.
synqt dev adds .dev-identities to the project's .gitignore the first time it reads
one. The file names the people who work on one machine. Committing it would put a
colleague's address in the repository, and give every clone a picker full of names that
mean nothing there.
Two tabs, two people¶
Tick this tab only and the session belongs to the tab you clicked in. You can then hold two identities in one browser and watch them interact, such as a moderator deleting the message a user is reading, in two tabs side by side, with no second browser profile or private window.
This works through the cookie's name. RFC 6265 scopes a cookie to a host, not a port, so
all tabs on one host share one cookie jar, and nothing else can tell them apart: the
WebSocket subprotocol alternative is unavailable on Qt 6.12
(tests/webedge/tst_webedge.cpp::theUpgradePathCannotNegotiateASubprotocol checks that).
So choosing a single tab sends it to /?s=<nonce> and stores its session under
synqt_session_<nonce>. The edge reads s from the page request and the sync URL to find
this tab's cookie in the jar.
The nonce only names which cookie to read, and nothing treats it as a credential: the
cookie still holds the session id, which is what an attacker would need to
steal. The edge validates the nonce on arrival, because it becomes part of a cookie name
in a Set-Cookie header, and a value containing ; or a newline would add attributes, or
a second header, that nobody intended.
Two identities, never conflated¶
SynQt has two separate identity systems, and keeping them apart is itself a security property.
- User identity is who the person in the browser is. The OAuth2 or OpenID Connect flow
on the web edge establishes it, as a session with a scope. It authorizes calls from
browsers (
Caller.isUser,Caller.session,Caller.scope).synqt add authconfigures it. - Entity identity is which service calls which over the mesh. The mutual TLS
certificate each entity holds establishes it (the entity name is the certificate
subject), on every mesh link by default, over loopback or across hosts. (An opt in local
socket link trusts colocation instead, and suits only equally trusted processes on one
host; see security.) It authorizes calls from entities
(
Caller.isEntity,Caller.entity). The mesh CA and the per entity certificates configure it (see[mesh]and security);synqt add authplays no part.
A browser user is never an entity, and an entity is never a browser user. A database slot
that checks Caller.entity === "edge" authorizes a service. An edge slot that checks
Caller.hasScope("admin") authorizes a person. The separation prevents mixing them up,
such as trusting a value a user supplied as an entity identity.
The login flow, end to end¶
sequenceDiagram
autonumber
participant B as Browser (client)
participant E as Web edge
participant P as Identity provider (OAuth2/OIDC)
B->>E: Session.login() navigates to the login route
Note over E: start Authorization Code flow, PKCE + random state (+ nonce for OIDC)
E-->>B: redirect to provider
B->>P: authenticate
P-->>B: redirect to edge callback (authorization code)
B->>E: callback (code, state)
Note over E: verify state
E->>P: exchange code for tokens (client secret, server side)
P-->>E: access/refresh tokens (+ ID token)
opt ID token used for identity
E->>P: fetch JWKS
P-->>E: signing keys
Note over E: verify ID token signature, iss, aud, exp and nonce
end
Note over E: map identity to scope (web/edge/identity/map.qml), create session
E-->>B: set httpOnly Secure SameSite session cookie
B->>E: reopen wss, cookie rides along (same origin by default)
Note over E: upgrade verifier validates the session, binds the connection
E-->>B: Session.scope and Session.identity update
The browser holds only the opaque session cookie. Every token stays on the edge.
A native desktop client runs the same flow with one
difference at the end. It has no origin for a cookie to be set on, so the edge
redirects the system browser to a loopback port the app is listening on and hands
back a one-time claim code, which the app exchanges for the session over its own
connection. Everything before that step, including where the secret lives, is
unchanged. The edge serves that exchange at <login route>/claim, and only when a
client entity lists the desktop target.
The identity object¶
Every signed-in session carries a normalized identity, so app code and the mapping hook read the same fields whatever the provider:
identity.sub: the stable subject. For OpenID Connect providers, it is the verified ID token'ssubclaim. For plain OAuth2 providers, the provider template maps the provider's stable user id into it (for GitHub, the numericid). Key durable ownership on it, as the examples do, never on an email or display name, which can change.identity.login: the provider username (GitHub:login), if the provider has one.identity.name: the display name, if the provider has one.identity.email: the verified email address, or null. From an ID token it is taken only when the token'semail_verifiedclaim is true, and a token without that claim gives none. A profile that statesemail_verifiedorverified_emailas false gives none either. Some providers withhold it. A GitHub account with a private email returns none from/user, so the GitHub template requests theuser:emailscope and falls back to the primary verified address from the emails endpoint; if the user granted nothing, it is still null. Code and mapping hooks must handle a null email. Prefersuborloginfor authorization decisions.
Provider templates define this mapping and document which raw fields feed each normalized one. A custom provider block does the same in its configuration.
The identity mapping hook¶
web/edge/identity/map.qml turns a provider identity into a SynQt scope. It runs only on
the edge, after a successful login. A project that signs anyone in must have one and must
declare scopes.order; synqt check refuses a project missing either, because otherwise
nothing decides a session's scope and every login fails.
import SynQt
IdentityMapping {
function scopeFor(identity): int {
const admins = ["owner@example.com"]
const moderators = ["mod@example.com"]
if (admins.indexOf(identity.email) !== -1) return Scope.Admin
if (moderators.indexOf(identity.email) !== -1) return Scope.Moderator
return Scope.User // any successfully authenticated user
}
}
The return value is a member of Scope, an enum SynQt generates from scopes.order, so
the hook needs no import. A member's value is the scope's index in scopes.order, which is
also its rank under scopes.hierarchical, and the edge resolves the answer by index, not
by name. Because it is an enum and not a string, a scope the project never declared cannot
be written here, and synqt check refuses a member the generator would not have written,
naming the file and line. When the edge cannot place an answer (from a hook that was not
regenerated, or one that failed to load), it refuses the login and logs why. A login that
cannot get a declared scope fails, with no fallback scope.
When roles live in a database, the hook can read a connect point the edge consumes (for
example a prop var assignments pushed by a roles entity, looked up as
Store.assignments[identity.sub]), so roles are data, not code. Read a pushed property,
not a returning slot: scopeFor is synchronous, because the edge needs the scope before
it can create the session, and a returning slot gives a promise instead of a value.
An identity service of your own works through this and
the customizations around it.
Session lifecycle¶
- Creation. A successful login creates a session with a bounded lifetime
(
identity.session.ttl_minutes). - Rotation. The edge rotates the session id when privilege changes (for example after a scope upgrade), which prevents session fixation.
- Refresh. When the provider issues a refresh token, the entity that holds the tokens
(the edge, or the auth entity when
provider_entityis set) renews the access token on the server, without the browser. Everyidentity.refresh.interval_seconds(60 by default), it renews any token withinidentity.refresh.margin_seconds(120) of expiry. Widen the margin for a provider with short lived tokens. An interval of zero or less turns the sweep off. - Unclaimed tokens. A finished exchange keeps the provider's tokens under the login's state key until a session is bound to them, which normally happens next. If the caller who started the login disappears in between, the entity holding the tokens drops them five minutes later, instead of keeping and refreshing a live refresh token for someone who never signed in. This sweep is always on: refreshing tokens is a project's choice, dropping an unclaimed secret is not.
- Expiry and revocation. A session expires at its TTL or is revoked (logout, or an
administrative action). A revoked or expired session fails the upgrade verifier. The
browser does not report a handshake's status code, so the client cannot tell this from
an edge that is down, and it retries with backoff. It reconnects with an anonymous
session, so
Session.isAuthenticatedgoes false and every scope-gated Replica is released; that is the signal for an app to route back to login. - Logout.
Session.logout()calls the edge's logout route, which clears the session on the server and expires the cookie. While revoking the session, the edge closes the browser connections it authorized, so nothing more reaches a signed-out tab, and the client returns as an anonymous visitor.
A desktop app that stays signed in between launches (identity.desktop_session: device)
works the same way. It keeps a single-use credential in the OS secure store, not a
session, and spends it at the next launch for a session of the same length, so the TTL,
rotation and revocation are identical. See
storing the session.
Where identity runs: at the edge, or as its own entity¶
By default, identity runs inside the web edge process. That is the simplest setup and right for most systems: one edge, and one place that holds tokens and issues sessions.
Larger systems, with several edges or services that need shared sessions, can move
identity to a dedicated auth entity by setting identity.provider_entity to that entity's
name. The auth entity owns the identity and session connect points, and the edges consume
them over the mesh, mutually authenticated like any mesh link. Token handling and session
state then live in one internal service, with the secrets in one place. Users see the same
flow; only where session state lives changes. It is a configuration change, not a
rewrite, because the edge already reaches identity through a connect point.
A replicated edge requires it, and
synqt check refuses replicas: > 1 without it. Four things move to the auth entity:
- The session table, so a visitor is not signed in on one replica and anonymous on the next.
- The hand-off after a scope change, so a visitor whose session
Caller.setScoperotated on one replica gets the new credential on their next page load, whichever replica serves it, instead of a fresh anonymous session. - The pending login, so whichever process the balancer sends the OAuth callback to can answer it, not only the one that started it.
- The desktop claim code, which the native client redeems over its own connection, independently of the browser that produced it.
Every replica presents the same entity identity, so to the auth entity they are one consumer running as several processes.
It takes one line, because everything else is generated. Declare the entity and name it,
and synqt build writes the two connect points (identity and sessions, one Source per
caller so one edge's answer never reaches another), the Source QML that bridges each to
its engine, and the entity's main.cpp with the OAuth engine and the authoritative session
store. Their contracts ship in the runtime library, so no project writes an export: for
them.
entities:
- name: auth # an ordinary service entity; it declares no connect points
type: service
identity:
provider_entity: auth
providers:
- name: github
client_id: your-client-id
client_secret: env:GITHUB_CLIENT_SECRET # now the AUTH entity's .env, not the edge's
The edge then receives only provider names: no client id, provider endpoint, client secret or token. It keeps the part that faces the browser (the login and callback routes, the origin and session checks, the cookie) and asks the auth entity over the mesh for every step that needs a secret. The scope mapping hook also stays on the edge: the auth entity establishes who someone is, and each edge decides what that means in its own system.
What the developer is responsible for¶
The framework provides secure defaults. A few tasks remain yours, and the scaffold lists them:
- Register the OAuth application with the provider, with its redirect URL set to the edge callback.
- Put the real client secret in the edge's
.env(never insynqt.yaml, never in a client target). - Decide the scope mapping in the identity hook.
- Decide whether the app allows anonymous reading (
identity.required: false) or requires login for everything (true).
Everything else (PKCE, state, cookie flags, token storage on the server, ID token verification, rotation, expiry, the origin and upgrade checks) is on by default, whether or not you remember it.