Channels
Each channel is wired inAddChannelAdapters and activated by the Veriqa:Channels:{Channel} section
with "Enabled": true. Eight channels come out of the box, each as its own package: the base set
as the Veriqa.Core.BaseChannels metapackage (Telegram, WhatsApp, Email), MAX, Instagram, Messenger, WeChat and Viber separately
(Veriqa.Core.ChannelAdapter.Max, Veriqa.Core.ChannelAdapter.Instagram,
Veriqa.Core.ChannelAdapter.Messenger, Veriqa.Core.ChannelAdapter.WeChat,
Veriqa.Core.ChannelAdapter.Viber), and a single channel as just that channel’s package. See
channel setup and supported channels.
Program.cs
Email works in two modes: Pull (magic link — the user follows a link from the email) and
Push (the one-tap email — the user sends a pre-filled message). Push is off by default and is enabled
with
Veriqa:Channels:Email:PushEnabled. The Email channel endpoints are mapped by
MapVeriqaAuthServer automatically when the channel is enabled — see the
quickstart, step 3.Telegram receives updates in
Webhook mode (default) or Polling — set by
Veriqa:Channels:Telegram:UpdateMode. For Webhook, provide WebhookBaseUrl and WebhookSecretToken.acr_values=channel:{type} — see the
quickstart.
Email: sending and receiving
Email is enabled through two independent modes under theVeriqa:Channels:Email section:
- Pull (magic link) — Veriqa sends an email with a link, the user follows it. On by default
(
PullEnabled). - Push (the one-tap email) — the user sends a pre-filled message to the inbound address, Veriqa
processes the incoming message. Off by default (
PushEnabled) — enable it together with the inbound setup and a sender verification policy. The flag is read at startup: restart the service after changing it.
appsettings.json
PublicBaseUrlis required when the channel is enabled — magic links and QR codes are built from it.Outbound:Smtp— SMTP for Pull mode (Port587 = STARTTLS;UseSsl: true= implicit TLS, port 465). With credentials set, STARTTLS is mandatory unlessRequireStartTls: false(defaulttrue). Keep the password in a secret store, not inappsettings.json.Inbound— inbound email for Push mode:InboundAddress,UsePlusAddressing(embeds the correlation token into an address likelogin+{token}@…),WebhookSecretTokento validate the provider webhook.VerificationPolicy— sender verification:DmarcAlignedPass(default),AllowListOnly(only the domains inAllowedDomains). TheNonevalue is forbidden in production.
Design presets and theme
The page’s look is set by the preset inVeriqa:AuthPageDesign:Preset:
The color scheme is controlled separately —
Veriqa:AuthPageDesign:Theme:
An explicit
Theme (Light / Dark) takes precedence over the preset’s color scheme; with
Theme: Auto the Dark preset also yields a dark page. The theme sets data-theme on <html>,
the preset sets data-preset. Animations respect prefers-reduced-motion.
The theme applies to every page Veriqa generates, not to the sign-in window alone: the sign-in
confirmation page, the page that collects missing details, the expired-link page and the email
sign-in pages all carry the same data-theme. With Theme: Auto they follow the visitor’s system
setting, so a stylesheet of your own that assumes light colors will get dark ones on a device set
to a dark theme — pin the scheme with Theme: Light if it must not.
appsettings.json
QR code visibility
Whether the sign-in page shows the QR code is set by theShowQrCode key of the
Veriqa:AuthPageDesign:QrCode section — the same section that holds the code’s resolution
(configuration reference):
appsettings.json
Where this section says tabs,
ChannelLayout: Grid puts the channels side by
side instead.
Two properties of DesktopOnly its name does not reveal:
- A tablet counts as a desktop. The QR code is scanned by another device’s camera, so a device of uncertain type keeps the code rather than losing it.
- When device detection fails, the QR code is shown. The device is determined by the page itself, and any failure of that check — a blocked script, an unrecognized User-Agent — leaves the QR code and the tabs in place. A page whose only way in is a button the user cannot reach is worse than a QR code shown where it is useless.
Preset and ShowQrCode are independent settings.
Channel buttons
On a phone underDesktopOnly, and on any device under Never, the page has no tab strip: the
channels are listed as buttons, one per channel, in the order of the channel set.
- The button for a messaging app — “Sign in with …” on a sign-in, “Confirm in …” on a confirmation — leads straight into it.
- Email with a mail-client button keeps that button, and the “Sign in via email link” link under it.
- Email with an address form gets a button of its own that reveals the form in place. A page whose only channel is this one shows the form straight away — there is nothing to choose.
- A channel hint appears under the button of its own channel.
Never the server renders this layout itself, so it needs no script. Under DesktopOnly the
page is rendered with tabs and switches to buttons once it has recognized a phone.
Channel button visibility
The QR code is the way into a channel from another device; the button that leads into it — “Sign in with …” or “Confirm in …”, and Email’s “Send confirmation email” — is the way in on this one. Whether that button is shown is set by theShowChannelButton key of the Veriqa:AuthPageDesign:ChannelButton section, the
counterpart of ShowQrCode:
appsettings.json
The pair in the example is what a screen read from across a room needs — a television, a kiosk, a
projector: the QR code on the big screen, the buttons on a phone, one way in on each.
What the setting covers:
- The buttons that lead into a channel: “Sign in with …” or “Confirm in …” of every channel, a third-party one included, and Email’s “Send confirmation email”. With that one hidden, the Email panel keeps its QR code and the “Sign in via email link” link.
- Not the controls that stay on the page: the address form of Email, the button that reveals that form among the channel buttons and the links switching the two Email sections are shown whatever the value.
- The sign-in window and the page that opens a confirmation. The pages a phone gets after scanning a hop QR code keep their buttons whatever the value: there the button is the only way forward.
MobileOnly:
- A tablet does not count as a phone, exactly as under
DesktopOnly: it gets the QR code and no button. - When device detection fails, the button stays hidden — the opposite of the QR code, which stays shown. A blocked script or an unrecognized User-Agent leaves a phone without the buttons; the QR code, which every allowed pair keeps on such a page, is then the way in.
ShowQrCode, the key can be set at each ownership level, and the nearest level that sets it
wins: the ui_config record (ChannelButton:ShowChannelButton under
Veriqa:UiConfigurations:Records:<code>) → the client entry (the same member in its entry of
Veriqa:OpenIddict:Clients) → the global section. The tenant level sits between the last two; as
with the footer block, no reader for it is registered out of
the box.
Allowed combinations of the two visibilities
The two keys are resolved independently, and each may come from a level of its own — a client entry that hides the buttons over a global section that hides the QR code. Every class of devices must be left with a way in, the QR code or the button:
A pair that is not allowed neither stops the host nor breaks the page: the page is rendered as if
ShowChannelButton were Always, and a warning naming both configured values goes into the log —
whenever such a page is requested, not once at startup. The check looks at the two settings alone,
not at the channels the page carries.
The instruction follows both settings
The built-in instruction under the title names only what the page shows. It goes into the markup in three wordings — for a page with the QR code and the button, with the QR code alone and with the button alone — and the stylesheet of the page shows exactly one of them, by the same attributes of the root element that show and hide the QR code and the buttons (data-qr-visibility,
data-channel-button-visibility, data-device).
Each wording exists for a sign-in and for a confirmation: a sign-in reads as in the table, any other
transaction takes the same wording with to confirm in {0} in place of to sign in with {0} and
to confirm in place of to sign in — for example Choose a channel and tap its button to confirm.
So a television under
ShowChannelButton: MobileOnly reads a wording that promises no button, and a
phone under ShowQrCode: DesktopOnly one that promises no QR code — there is nothing to reword by
hand. A browser in which device detection failed gets the wording of a desktop, the same way it gets
the desktop’s QR code and buttons.
Which column applies depends on how the page lays out its channels:
- One channel — the first column; the channel is named by its icon and name.
- Several channels behind tabs — the wording of the selected tab: a channel tab, with one QR code and one button, takes the first column with that channel named. The button alone is a phone without the QR code, where the tabs turn into buttons, so that row comes from the second column.
- Several channels on screen at once — the grid, the buttons the server lays out — the second column.
- One QR code for several channels, which a page shows in
hop mode. Under
QrMode: Unifiedthe page has no tabs and takes the last column. UnderQrMode: UnifiedWithChannelsthe QR code sits on the “Any channel” tab, followed by a tab for each channel: a channel tab takes the first column, and the “Any channel” tab, where the user picks one of several messaging apps, readsScan the QR code or tap your messaging app's button to sign in(… to confirm) in place of the first row of the last column. WithShowChannelButtonsOnAnyChannelTabset tofalse, that tab shows the QR code alone and readsScan the QR code to sign in. On a phone that hides the QR code, the tabs turn into buttons and the page readsTap a channel button to sign in.
Enter your email to get a link on a sign-in and a confirmation alike. A channel whose QR code
could not be drawn, on a page that hides the channel buttons, leaves its panel with neither, and that
panel gets no instruction.
When the user switches tabs, the script swaps the text. The block keeps the height of the longest variant, so
nothing under it moves, and the change is not announced to a screen reader, which already announces
the selected tab. A browser with JavaScript turned off shows the wording of the tab selected on load —
“Any channel” where the page has it, the first channel otherwise. An instruction
set in configuration replaces the page’s own:
it is one wording, and the tabs do not change it.
The tabs of a page with the “Any channel” tab. “Any channel” takes a row of the tab strip on its
own; the channel tabs follow, two to a row, and the last of an odd number spans the whole row. A
channel tab whose panel shows that channel’s own QR code is labeled with the built-in text {0} ▦:
the name of the channel goes into {0}, and the mark ▦ is drawn as a QR code icon in the color of
the tab text, so the user sees which tabs hold a QR code. A tab whose panel shows no QR code of its
channel — Email signing in with an emailed link — carries the name alone. The label can be reworded
like any other text: a replacement keeps {0}, each ▦ in it becomes the icon, and a replacement
without the mark shows no icon.
A page of one channel names the channel. The page fills the {0} slot with the icon of the
channel followed by its name — the pair its tab would carry; a channel that has no icon is named by
the name alone. A page of several channels names none.
Each of the ten wordings is a built-in text, so it is reworded like any other: for one application
in its AuthPage.Texts dictionary, for the whole
installation in the locale files. Two things to keep in mind for the three texts with a slot:
- a replacement in
AuthPage.Textshas to carry{0}, as every replacement of a text with a slot does; a translation in a locale file that drops it is shown as is, and the channel then goes unnamed; {0}goes in the text, never inside a tag. A wording that puts it into an attribute value —<a href="https://your-domain.com/help?c={0}" target="_blank">— is shown as plain text, with a warning in the log (see Markup in the title, the instruction and a channel hint).
Instruction of a ui_config record replaces the whole instruction — one wording for every
device, with no channel named. A page whose only channel is Email with an address form shows neither
a QR code nor a channel button, yet gets the same one-channel wording; give such a page an Instruction
of its own.
The page wording follows whether the QR code is shown
Strings that name the code or the button go into the markup in every wording at once: the instruction in the three above, the “back to the QR code” link of the Email panel as a pair — “with QR” and “without QR”. They are switched by the same conditions as the blocks they describe: rules keyed on the root element’sdata-qr-visibility, data-channel-button-visibility and data-device
attributes — for the pair, the .veriqa-if-qr / .veriqa-if-no-qr classes. The script of the page
takes no part in the choice. That way the page never promises a QR code or a button it does not show.
This constrains your own CSS (CustomCssPath) and script (CustomJsPath): a rule
that overrides the display of these wordings decouples the text from what the page shows — leaving,
say, “scan the code” on a page with no code. If you hide or show the QR code or the buttons by your own
means, switch the wordings by the same conditions.
The server always generates the QR code, even when the page does not show it: the PNG goes into the
markup as a data URI and stays hidden. Under
Never this only adds to the response size.Channel layout
Where a page shows several channels, each with a QR code of its own, the channels share the page through tabs — one on screen at a time. TheChannelLayout key of the Veriqa:AuthPageDesign section puts all of
them on screen at once instead:
appsettings.json
Under
Grid:
- The card grows with the number of channels. Its width is the usual one column (420px) plus a column for every further channel, and never more than the window less the margins the card always keeps. Panels that do not fit a row wrap onto the next line — down to one per line on a narrow window.
- A panel holds what its tab would show: the QR code, the channel button (subject to
ShowChannelButton), the Email form, the channel hint. - There are no tabs in the markup. Each panel is a group labeled by the visible name of its channel, so a screen reader announces the channels rather than “tab 1 of 3”.
- a page of one channel;
- a page laid out as channel buttons — any device under
ShowQrCode: Never, a phone underDesktopOnly; - in hop mode, a page that shows one QR code for all its
channels — also where that QR code sits on the “Any channel” tab before the channel tabs
(
QrMode: UnifiedWithChannels): its tabs stay tabs.
ChannelDisplay:Mode and the channel set.
The layout applies to the sign-in window and to the page that opens a confirmation. It is set at
the same levels as ShowChannelButton: the ui_config record (ChannelLayout under
Veriqa:UiConfigurations:Records:<code>) → the client entry (ChannelLayout in its entry of
Veriqa:OpenIddict:Clients) → the global section.
Your own stylesheet. A rule of
CustomCssPath that sets the width of .veriqa-container
narrows the card of the grid back to one column, and the row turns into a stack. Write it for the
one-column card only: .veriqa-container:not(.veriqa-container--grid).Cancel button
The sign-in window carries a Cancel sign-in button — the way back to the application for a user who has changed their mind. It sits below the channels, the status line and the countdown, above the footer block. The button is on by default; theEnabled
key of the Veriqa:AuthPageDesign:Cancel section switches it off:
appsettings.json
- The sign-in ends as declined by the user — the transaction fails with the reason
declined_by_user, the same as a “No” on the web confirmation page. - The browser returns to your application — to the
redirect_uriof the request, with the standarderror=access_denied, thestatethe request carried and the issuer identifieriss(RFC 9207), in the response mode it asked for (query,fragmentorform_post). Your application handles it like any other declined sign-in. An ASP.NET Core client onAddOpenIdConnecthas to be told where to send the user: withoutAccessDeniedPaththe handler throws onaccess_deniedand the return ends in an HTTP 500 — see step 5 of the .NET quickstart. - A confirmation arriving from the channel at the same moment does not win: no authorization
code is issued, and the application still receives
access_denied. - The chat where the question waits is told: when the sign-in was waiting for the user’s answer
to a question already shown in a messaging app chat, that chat gets the same “Sign-in declined” receipt
a press of the decline button gives. Where the platform can edit a sent message — Telegram, for
one — the receipt replaces the question and its buttons go away; elsewhere — Instagram, Messenger,
WhatsApp and the like — it arrives as a new message and the buttons of the question stay, a later
press being answered with the same receipt. Nothing is sent when the question is not in a chat:
with Email, or when it is asked on the web page. Whatever happens to the receipt, the application
receives
access_denied.
ui_config record (Cancel:Enabled
under Veriqa:UiConfigurations:Records:<code>) → the client entry → the global section. To leave
one application without the button, set it in that client’s entry:
appsettings.json
Cancel sign-in — the key the cancel action of the claims
completion step already uses, so the translations you have apply to the button as well. To reword
the button of the sign-in window for one application, name Cancel sign-in in its
AuthPage.Texts dictionary.
What the button changes in the response of the sign-in window. The
form-action directive of
its Content-Security-Policy names the origin of the client’s redirect_uri next to 'self' (or, for
an address with a custom scheme, that scheme): a browser applies the directive to the whole
chain of redirects after a form post, and the post of the button ends on the redirect_uri. The
response also sets the antiforgery cookie and is sent with Cache-Control: no-cache, no-store.
With the button switched off the response is what it was.Branding
The client’s logo and brand name fill the brand row at the top of the sign-in card in every preset butMinimal; the primary color applies only in the Branded preset:
appsettings.json
PrimaryColor— strictly#RRGGBB; an invalid value falls back to a neutral default. It only colors shared elements (the primary button, focus rings, the spinner).LogoUrlandBrandNametogether replace the product’s own mark. With neither of them set at any level, the brand row shows the red diamond andVeriqa. Setting either one replaces that mark as a whole: a name alone shows no diamond, a logo alone shows noVeriqa. An unset logo therefore does not mean “no brand row” — for a page without one, use theMinimalpreset. Pages other than the sign-in window (web confirmation, an expired sign-in, the email pages) show no brand row at all.BrandName— the name beside the logo, in place of the product’s own wordmark. The two are resolved independently: set either one alone and the page shows what there is — the name alone, the logo alone, or both. Unlike the logo the name has no per-theme variant (an image cannot repaint itself for a dark page, text takes the page’s color), soBrandNameByThemedoes not exist. A blank value sets nothing: the name then comes from the level below. The value is plain text — it is encoded on output — and a localization key (see Texts set in configuration).PrimaryColorByThemeandLogoUrlByThemeset the value for a single theme (light,dark). A theme without a value of its own takes the neighboring shared key (PrimaryColor,LogoUrl). WithTheme: Autothe browser picks the theme, and both values — the light one and the dark one — travel to the page.- Messenger brand colors are never recolored, in any preset.
Footer block of the sign-in page
FooterHtml puts markup of your own at the bottom of the sign-in card — links to a privacy policy
and terms of service, a short legal note, a support contact. The block is shown in every preset,
Minimal and a custom stylesheet included, and on both pages that render the sign-in window: the
sign-in itself and the page that opens a confirmation. No other page Veriqa generates carries it —
the confirmation pages, the expired-link page and the email sign-in pages have no footer block.
appsettings.json
Veriqa:OpenIddict:Clients), where an application sets a block of its own instead of the global
one. A ui_config record does not set it: a record is chosen by a request parameter, and what
a request may choose is the look of the page, not markup on it.
The
Tenant level. The setting exists at the tenant level as well, but no reader for that
level is registered out of the box — the integrator writes one. Without it the resolution goes to
the client entry and to this section.FooterHtml shows the block of the global section: a lower level cannot switch off the block
an upper one sets, it can only replace it. To leave one application without a block, set the
block in the entries of the applications that need it rather than globally.
What the markup may contain
The value is checked against a strict shape and is rejected as a whole when it steps outside it — nothing is stripped, nothing is repaired:- text — any characters except
<and>. A bare&is fine; a bare>is not, write it as>; - tags
a,span,div,p,strong,em, and the voidbr(<br>,<br/>,<br />— there is no</br>). Names of tags and attributes are case-insensitive; - attributes
href,target,relandclass, each at most once per tag, with the value in double quotes and free of",<,>. Anything else —style,id, anyon*handler — is refused, and so are single-quoted and unquoted values; - no class name of the page itself: a
classis refused when one of its names isveriqaor begins withveriqa-, in any letter case and after character references are decoded. A name that merely starts with the word —veriqable— is fine, and so is an emptyclass; - every link carries
hrefandtarget="_blank". The address is judged in the form the browser reads it — after character references are decoded — and must behttps://…,http://…or a root-relative/path. A protocol-relative//host, ajavascript:ormailto:address, a backslash or a control character inside the address are refused; - the tags are balanced: a closing tag closes the innermost open one, and none stays open at the end;
- comments,
<!DOCTYPEand any tag outside the list are refused.
target="_blank" is not a matter of taste here. Leaving the page in the same tab ends the sign-in
running on it, and coming back requests GET /connect/authorize anew, which creates a new
transaction — a link that opens in place costs the user the sign-in they had started.
Why
veriqa- class names are refused. They belong to the markup of the page: its script
reaches its own elements by class (.veriqa-tab, .veriqa-panel, .veriqa-tooltip-host,
.veriqa-tooltip), so a block carrying one of them would affect the behavior of the page it sits
on — a class="veriqa-panel" inside the block would be hidden by the channel tabs. Name your own
classes with a prefix of your own and style them from your stylesheet (CustomCssPath).What an invalid value does
Translating the block
The value is a Natural Key like every other text of the page (see Texts set in configuration): the page looks it up in the host locale files, and a value no file carries is emitted as is. The translation is checked against the same shape as the value — a translation that steps outside it leaves the page without the block. The context suffix applies too, and with markup it is worth using: the block is a long string, and a label keeps it from colliding with anything.appsettings.json
ru.json
| is reserved for that label: everything after the last | is cut off the
text the page shows. Markup that has to contain | of its own does not fit this convention — use a
| entity or another separator.
Markup in the title, the instruction and a channel hint
Three texts of the sign-in window take inline markup of your own — a word in bold, a link, aspan
with a class of yours:
- the title — the
Titleof aui_configrecord, or your wording of the built-in title inAuthPage.Textsor in a locale file; - the instruction under it — the
Instructionof a record, or your wording of a built-in instruction; - a channel hint — an entry of
Veriqa:ChannelDisplay:Hints.
appsettings.json
- tags
span,strong,em,aand the voidbr.divandpare refused here: the text already sits inside a heading or a paragraph; - attributes
href,target,relandclass, under the rules of the footer block — double quotes, at most once per tag, nothing else; - every link carries
hrefandtarget="_blank", for the same reason: a link that opens in place ends the sign-in running on the page; - no class name of the page itself —
veriqaand anything beginning withveriqa-are refused; - no
imgand nosvg. An icon is aspanwith a class of yours, drawn by your own stylesheet (CustomCssPath):<span class="acme-mark"></span>. Attaching a stylesheet switches the design preset off — see External CSS.
Terms & Conditions shows “Terms & Conditions”. Write a literal < or >
as < and > — a bare one fails the check, and the value is then shown as written, with the
warning above.
The browser tab gets the title without its tags. The <title> of the document carries the text
of the title alone, with a <br> turned into a space.
The same holds on both pages that render the window — the sign-in itself and the page that opens a
confirmation. A renderer of your own receives these values raw — see
Sign-in page renderer.
Instruction after the scan
In hop mode the page learns that the user has continued on the phone before the confirmation arrives: the QR code was scanned, the page opened on the phone, the transition into the messaging app started — the phone page starts it by itself, and its button starts it again on a tap. That last step says the browser began to open the app, not that the app opened. The status line names each such step. The scan names what follows on the phone: “QR code scanned. Continue in Telegram.” for a messaging channel, “QR code scanned. Continue on your phone to confirm by email.” for Email, and “QR code scanned. Continue on your phone.” for the QR code shared by every channel. When the phone page opens the app by itself, the last two steps arrive together, and the status line shows only “Opening …” instead of flashing both. On the first of them the page takes the channel choice away — the tabs, the QR codes and the channel buttons, with the instruction under the title that describes them — and shows an instruction in their place: by default “Continue on your phone and confirm in the app that has opened.” The subject of a confirmation, the status line and the countdown stay. Below the instruction is the “Choose another way” button: it brings the channel choice back on the same page, with the same sign-in — for a user who scanned with the wrong device or changed their mind. The next step reported from a phone, the same phone included, shows the instruction again. Once a channel has answered the start — its bot sent the question into the chat — the button disappears from the page for good: the sign-in is bound to that channel’s account from then on, and another channel could only end in a refusal. Where the status line then says “You have moved to …” — on a sign-in and a confirmation alike — the instruction disappears together with the channel choice: the page keeps the title, the status line, the countdown and “Cancel sign-in”. A page that fell back to polling does not hear the answer and keeps the button. Reloading the page brings the channel choice back as well; once the sign-in expires, the instruction disappears and the restart link appears.HopInstructionHtml replaces the core’s text with markup of your own:
appsettings.json
ui_config record), a blank
value sets nothing, the same allowed shape and the same outcome of an invalid value, and the
value is a Natural Key. The exception is a translation outside the shape: the page then shows the
core’s own text rather than no block, because the block sits where the channel choice was, and an
empty one would leave the user without a word on what to do next. A warning naming the setting and the
language goes into the log.
The “Choose another way” button is not part of the setting: it sits beside your markup, not inside
it, and its caption is a text of the sign-in window like any other.
The setting has no style of its own. Style the block from your stylesheet (CustomCssPath) through
its veriqa-hop-instruction class; the page’s own rules set only its text color, size and spacing.
CSS variables
The pages are styled through CSS variables prefixed--veriqa-*, which you can override in your
own CSS. The main names are below; the full set is declared in the package stylesheet and also
includes the --veriqa-spacing-* scale and the colors of the service-page badges:
External CSS
Attach your own stylesheet viaCustomCssPath — it loads after the base styles and can
override any variable. When CustomCssPath is set, the preset is ignored: your CSS fully defines
the look.
The stylesheet is attached to every page Veriqa generates: the sign-in window, the sign-in
confirmation page, the expired-link page and the email sign-in pages. Your CSS styles them all
alike — there is no separate setting for the service pages.
The expired-link page always offers one action. When the application sets InitiateLoginUri —
its own address for starting a sign-in (the Client.InitiateLoginUri key of the
configuration reference) — the page links to it as “Start the sign-in
again”; the address must be an absolute https URL. Without one the page shows a “Close” button
instead. The texts of this page can be replaced like the texts of the sign-in window — see
Rewording a page element for one application. The page belongs to a sign-in only:
a link of the sign-in flow that names an action confirmation leads to the neutral “This link is no
longer available” page instead.
appsettings.json
CssSriHash (Subresource Integrity) if styles load from an external address — external
resources on the sign-in page are disallowed by default.
Per-client UI configurations (ui_config)
Beyond the global look, you can define named UI configurations and bind them to a specific OIDC client. The catalog of records is theRecords map of the Veriqa:UiConfigurations section
(“record code → look”), so the full path of a record is Veriqa:UiConfigurations:Records:<code>;
a record overrides the global look for its code.
appsettings.json
DefaultUiConfig— the record code applied by default for the client.AllowedUiConfigs— codes the client is allowed to request. A code from this list is valid even without a catalog record — the global look is then used.
Localization
The sign-in page uses the Natural Keys approach: the localization key is the English source text, anden is the base language (it needs no translation file). The supported-language
registry is data-driven and built from the locale files your host ships: JSON dictionaries
{lang}.json in wwwroot/locales (the path is configurable via
Veriqa:Channels:Localization:LocalesPath) plus the base en. Out of the box the page renders in
English; to add a language, put its {lang}.json into the locales directory — no code changes
required. You do not have to translate Russian and Chinese at all: they are shipped, see
Ready-made translations below. The directory itself is optional: a host
without it logs Locale files directory not found — channel messages are sent in the base language.
on startup, and that warning is expected.
Ready-made translations
The whole catalog — everything a user reads while authenticating — is published as a content-only package,Veriqa.Core.ChannelLocales. It carries en.json, ru.json, zh.json and
pseudo.json (a layout-test locale rather than a production language: it is detectable through an
explicit Accept-Language, but refused as DefaultLanguage).
PackageReference
- copy the files you need out of
veriqa-channel-locales/into your own locales directory. They are yours from then on, and you are free to edit any wording in them; - point at it: set
Veriqa:Channels:Localization:LocalesPathto the directory the package lays down. A relative path is resolved from the host’s content root, so set a path that is right for the way you run the application — or an absolute one. Nothing there is yours to edit: the next version of the package replaces those files entirely.
en.json is the
authoritative set of keys, and a key missing from your files quietly falls back to English for the
user.
Which language a user gets
The sign-in page and the messages in the channel take their language from different sources, and the two legitimately differ. The sign-in page follows the request: the OIDCui_locales parameter first — an explicit choice
made on the relying party’s side — and the browser’s Accept-Language after it. A value matching no
language of the registry falls back to the default language:
appsettings.json
DefaultLanguage is validated at startup: the value must be in the registry (the host’s locale
files + en), otherwise the application does not start. For example, "DefaultLanguage": "ru"
requires the host to ship wwwroot/locales/ru.json.
The language the page was rendered in travels on the transaction, and every later page and
message of that sign-in falls back to it — including the form_post auto-posting page that delivers
the authorization response, which renders in that language instead of reading the browser header
again. Accept-Language with the default language stays the fallback for responses that never had a
transaction, such as errors returned to the redirect_uri. A confirmation created
server to server has no page at all — what travels there is the
locale field of the request.
A message inside a channel follows the recipient instead. A channel that knows the user’s profile
language supplies it with every event, and it outranks everything the browser said: a user whose
Telegram is in Russian gets a Russian prompt from a page rendered in English. A channel that supplies
nothing falls back to the locale the transaction carries, and failing that to the base language.
Email is a third case, because its texts are not read inside a messaging app. The sign-in email goes out
before anyone has answered, so there is no profile to ask: it is worded in the language of the request
that asked for the email — that browser’s
Accept-Language — falling back to the locale of the
transaction. The confirmation page the magic link opens does the opposite: it prefers the
transaction’s locale over the Accept-Language of the mail client’s browser, so that page matches the
sign-in window the user started in.
One installation may therefore speak two languages in one sign-in, and that is the intended behavior
rather than a misconfiguration. What it does require is that every language involved is present in the
locale files: a language nobody translated degrades to the base one silently.
Channel and email texts — the same locale files
The same directory drives more than the sign-in page: nearly everything the user sees in the messaging app and in the email — button captions, the status after their answer, the visible lines of the email — comes from there. One rule: the key is the English text, the value is what the user sees. To reword anything, add the key with your value to the locale file of that language — no code involved. The main keys:Which of the two confirmed texts a user gets is decided by the message template, not by the
locale file. The receipt of a sign-in is the message kind
outcome-receipt-confirmed, and its
template narrows by the device the sign-in was started on — the ByDevice group, with the values
desktop, mobile, tablet and unknown. The product ships the short text for every device and
the text with the way back under ByDevice:mobile and ByDevice:tablet. The locale file only
translates each of them. If you set the receipt of a sign-in in the configuration of the host
without ByDevice, phones and tablets still get the shipped text with the way back — set your
own ByDevice steps as well. A wording set for one application, tenant or ui_config record
answers for every device by itself. The addresses and an example:
Configuration reference.These are installation-wide product defaults: locale files are a host asset, one set per
process. Per-tenant wording is not configured here: the texts of the pages Veriqa renders are
reworded for one application or tenant by a dictionary of its own.
Every visible phrase of the shipped sign-in email is a localization key. The body of the email —
both the HTML and the plain-text part — holds no wording of its own: each phrase is a
localized-text slot, so an email in the recipient’s language is in that language throughout, and
translating the email is editing a locale file. A phrase does not carry a substitution inside it:
the value sits beside its label —
The link is valid until: followed by the time,
The request came from: followed by the browser, the system and the region — and the name of the
application has a line of its own under the heading. The subject Sign in to {app} is the one text
translated as a whole. The same holds for the claims-completion emails, claim-code-link-mail
included.A translated phrase is limited to 128 characters, like any value that comes from outside the
product; a longer one is cut. Values Veriqa computes itself — the link, the code, the validity
time — are substituted in full, whatever their length. If you
set your own email body, split a long sentence into
several slots rather than one.One phrase, two translations: the context suffix
The key IS the English text, so one phrase yields one translation per language. When the same English phrase must translate differently in different places (one channel worded unlike the rest, say), the key carries a context label after|:
ru.json
- the label is metadata — the user never sees it. With no translation available, the part of
the key before the last
|is shown, i.e. the clean English text, not… | telegram; - never map a key to itself (
"X | telegram": "X | telegram") — that is the one way to put the label in front of a user; |is reserved for the label: ordinary UI text must not contain it;- add a context only on a real divergence. By default one phrase is one key with no suffix; there is no value in contexts whose translations coincide.
Texts set in configuration are localization keys too
The texts aui_config record sets go through the same resolution as the built-in ones: the
configured value IS a Natural Key. There is no such thing as an unlocalizable text in the product.
The confirmation text is set by a message template, not by a field of the record. The
wording of the confirmation request is a message, and it is set by the ladder of its template:
Veriqa:MessageTemplates:confirmation-prompt:Templates (how the key is built and which levels it has
— in the Configuration reference). The rule of
this section applies to it in full: the text you set is a Natural Key just the same and does not
bypass localization. If one application needs a wording of its own, set it in that client’s entry
(the Application level) rather than globally.- a value with no locale entry is shown as is. The key IS the text, so a miss breaks nothing: the user sees exactly the string you set, merely untranslated. The base language needs no entry at all;
- to have the text translated, add its key to your locale files — the same
wwwroot/locales/{lang}.jsonas every other text. The locales directory is a host asset, and the host is you: the keys of yourui_configrecords go in there alongside the product ones.
<text> | <label> string and the
translation goes into the locale files.
appsettings.json
ru.json
- the label is metadata: everything after the last
|is invisible to the user. The valuePrivacy | Termsrenders asPrivacy— a setting whose own text has to contain|does not fit this convention; - a miss is not a failure: with no locale entry the user sees
Sign in to the portal— the right text, merely untranslated. No label, no blank, no error; - you choose the base text: the value
Политика | authpagewith no English translation shows an English-speaking user Russian. The behavior is predictable, but the base text is best written in the base language (en); - the label is not always needed. A text of yours that is already unique can be translated with
no suffix — the value itself is the key. The label guards against colliding with a product key
and separates one phrase across different
ui_configrecords.
Rewording a page element for one application
A locale file rewords a text for the whole installation. When one application needs its own wording of a built-in element — a feedback form that confirms a message rather than signs anyone in, say, and wants “Send in WhatsApp” instead of “Confirm in WhatsApp” — set it in theAuthPage.Texts
dictionary of that application. An entry is keyed by the built-in text of the element — its
English text, the same string that keys it in the locale files, spelled as in the list below — and
specifies what to show in its place.
appsettings.json
ru.json
AuthPage:Texts in its record), the
application (AuthPage:Texts in its entry of Veriqa:OpenIddict:Clients) and the ui_config record
(its texts member; in appsettings.json — Veriqa:UiConfigurations:Records:<code>:Texts). There
is no installation-wide level: that is what the locale files are for. The levels merge entry by
entry: for each text the most specific level that sets an entry wins, and the entries a lower level
sets for other texts stay in force. So a tenant may reword one button and an application another, and
both show.
The
Tenant level. As with the footer block, no reader
for the tenant record is registered out of the box — the integrator writes one. Without it the
dictionary comes from the client entry and the ui_config record.- a string is a replacement key. It goes through the locale files exactly like a text
set in configuration: the page looks the
replacement up in the locale file of its language, and with no translation shows it as is. The
context suffix
|is allowed and advisable — above,feedbackkeeps the replacement from colliding with a product key; - an object holds ready-made texts by language. They bypass the locale files: the page shows the text
of the language it renders in as is. Name the languages the way your locale files are named
(
en,ru,zh); the page renders in the language of the request when your locale files translate it, and in English otherwise. A language the object does not name gets the regular translation of the built-in text, not another language of the object — in the example above a user reading Chinese gets the Chinese translation of “Sign in with Email” from your locale files.
Link sent to {0}. Check your inbox., Confirm the address {address} to sign in to {app}. A replacement — the string, or each
text of the object — has to carry exactly the same slots. One that drops or adds a slot is not
applied: the page shows the built-in text the regular way.
The button for a messaging app: a phrase for one channel outranks the template. The button is the
template Sign in with {0} on a sign-in and Confirm in {0} on a confirmation, with the name of the
channel in the slot: the built-in text <Name> | channel of a built-in channel (Telegram | channel,
WeChat | channel …) — one name on the button, on the tab and in the instruction — or, as is, the
name a third-party channel reports. The page puts the English name into the template
(Confirm in WhatsApp) and looks that phrase up first:
- an entry for the phrase;
- an entry for the template;
- a translation of the phrase in the locale files;
- a translation of the template.
Confirm in {0} rewords the button of every channel; an entry for Confirm in WhatsApp
rewords that one channel. A locale file does the same for a language whose grammar the template cannot
hold: a Turkish file translates Confirm in {0} as {0} üzerinden onayla and adds
"Confirm in Viber": "Viber'de onayla" for one channel. Phrase entries work for the built-in channels only; reword the button of a third-party channel through the template or a locale file. The
confirmation creation API returns the channel name untranslated.
Which texts can be reworded. The dictionary reaches the pages Veriqa renders for the user during a
sign-in or a confirmation:
- the sign-in window — on both pages that render it, the sign-in itself and the page that opens a confirmation — including the status lines its script shows;
- the launch page of hop mode on the phone (its heading “Sign in via …”, its instruction, the status line once the transition has started and the line that sends the user back to the sign-in window) and the lines the window shows on a phone that opened a sign-in by its QR code — the outcome and the way back to the sign-in window;
- the email pages: the page the magic link opens — the request of a sign-in and the line it turns into after “Continue signing in”, the “Continue” page of a confirmation and the line of the fast link — the page that opens the mail app for a sign-in by email, the form that requests a sign-in link, and the page of the link that confirms an email address during claims completion.
The built-in texts an entry may name
The built-in texts an entry may name
Sign-in window:
Sign in via trusted channel · Confirm via trusted channel ·
Scan the QR code or tap the button to sign in with {0} · Scan the QR code to sign in with {0} ·
Tap the button to sign in with {0} · Scan the QR code or tap the button to confirm in {0} ·
Scan the QR code to confirm in {0} · Tap the button to confirm in {0} ·
Enter your email to get a link ·
Choose a channel, then scan its QR code or tap its button to sign in ·
Choose a channel, then scan its QR code to sign in · Choose a channel and tap its button to sign in ·
Choose a channel, then scan its QR code or tap its button to confirm ·
Choose a channel, then scan its QR code to confirm · Choose a channel and tap its button to confirm ·
Scan the QR code or tap a channel button to sign in · Scan the QR code to sign in ·
Tap a channel button to sign in · Scan the QR code or tap a channel button to confirm ·
Scan the QR code to confirm · Tap a channel button to confirm ·
Scan the QR code or tap your messaging app's button to sign in ·
Scan the QR code or tap your messaging app's button to confirm ·
Sign-in QR code · Any channel · {0} ▦ · Sign in with {0} · Confirm in {0} ·
Telegram | channel · WhatsApp | channel · Instagram | channel · Messenger | channel ·
WeChat | channel · Viber | channel · MAX | channel · the phrase of each of those
channels in Sign in with {0} and Confirm in {0} (Sign in with Telegram … Confirm in MAX) ·
Sign in with Email · Send confirmation email · Sign in via email link · OR ·
← Back to QR code · ← Back · Email address · Get a link · Send again · Link sent to {0}. Check your inbox. ·
Failed to send the link. Please try again. · Email link opened. Confirm the sign-in. ·
The confirmation email is open in your mail app. Send it to continue. ·
If you have already sent the email, wait for the confirmation. It usually takes a few seconds. ·
Email received. Verifying the sender… · Sender verified. Completing sign-in… ·
Sign-in has already started in another channel. · Confirmation has already started in another channel. ·
QR code scanned. Continue on your phone. · QR code scanned. Continue on your phone to confirm by email. ·
QR code scanned. Continue in {0}. ·
The page is open on your phone. · Opening {0}… ·
Continue on your phone and confirm in the app that has opened. · Choose another way ·
Back to channel selection ·
On iPhone, a browser page with two buttons may open first. Tap the one that opens Messenger, then confirm that you want to open the Messenger app. ·
Problem signing in · This QR code was also scanned on another device. If that wasn't you, start over. ·
Complete the form on your phone · Waiting for confirmation… · Confirmed. Processing… ·
Sign-in successful! · This sign-in request has expired · Start the sign-in again · Cancel sign-in ·
Authentication error ·
Send the code in the chat with the bot {0} if the bot has not sent you a confirmation ·
Send the code in the chat with the bot if the bot has not sent you a confirmation ·
Copy and open {0} · Copy · Copied ·
You have moved to {0}. Tap the confirmation button in the chat with {1} if you want to continue signing in, or tap cancel. ·
You have moved to {0}. Tap the confirmation button in the chat if you want to continue signing in, or tap cancel. ·
You have moved to {0}. Answer the question in the chat with {1} to confirm or decline. ·
You have moved to {0}. Answer the question in the chat to confirm or decline.
(the last four are the status line once the bot of a channel has answered the start — the first two
for a sign-in, the other two for a confirmation)Expired sign-in session page: Sign-in session expired ·
This sign-in link has expired or was already used. Please start the sign-in again from the application. ·
Close · You can close this tab. (its link is Start the sign-in again, listed above)Phone pages of a scanned sign-in: Sign in via {0} · Continue in {0} ·
The app should open by itself. If it does not, tap the button below. ·
The {0} app should open by itself. If it does not, tap the button below. ·
A browser page with two buttons will open. Tap the one that opens Messenger, then confirm that you want to open the Messenger app. After that the chat with the bot will open. If nothing opens, tap the button below. ·
You have moved to {0}. Tap the confirmation button in the chat with {1} if you want to continue signing in, or tap cancel. ·
You have moved to {0}. Tap the confirmation button in the chat if you want to continue signing in, or tap cancel. ·
You have moved to {0}. Answer the question in the chat with {1} to confirm or decline. ·
You have moved to {0}. Answer the question in the chat to confirm or decline. ·
(the four lines above are shared with the sign-in window, and so are Problem signing in and
Copy and open {0}, the link and the button of the spare start code, and Continue in {0}, the
heading of the sign-in window a phone opens by a QR code of several channels once a channel is tapped) ·
Sign-in confirmed. Continue on the device where you started. ·
This sign-in is no longer active here. Check the device where you started. ·
Return to the sign-in window and confirm the sign-in there. (shared with the email pages: an entry
changes it on the phone pages and on the page of the magic link alike)Email pages: Sign-in request ·
A sign-in to {app} was requested. If it was not you, close this page. · Continue signing in ·
Return to the sign-in window and confirm the sign-in there. · Cancel · Approve sign-in · Continue ·
Sign-in confirmed. Continue on the device where you started. ·
The link has expired. Request a new one. · The link has already been used. Request a new one. ·
The link is invalid or has expired. Request a new one. ·
Something went wrong. Try requesting a new link. · Confirmation required ·
Continue to see what you are asked to confirm. ·
The sign-in link has been sent to {email_masked} · Send again ·
Failed to send the link. Check the address and try again. ·
The Email adapter Pull mode is disabled. · Sign in via email link · Sign in with a link ·
Enter your email — we will send you a sign-in link. ·
Session not found. Return to the sign-in page. · Email address · Get a link ·
Confirm your email address ·
Confirm the address {address} to sign in to {app}. ·
If you did not sign in to {app}, do not confirm the address. · Confirm the address ·
The address is confirmed. Return to the sign-in page. ·
This link no longer works. Request a new one on the sign-in page. · Close ·
You can close this tab.your@email.com, and the emails — an email is worded by its
message template.
One text is one element everywhere. An entry changes the text wherever a covered page shows it:
Sign in with {0} is the button of the sign-in window and of the launch page on the phone, and
Sign in via email link is both the tab of the window and the title of the link request form. On a
page the dictionary does not cover, the same text stays built-in — an entry for Confirmation required changes the
“Continue” page of the email link and the error page that link answers with, not the confirmation
page that carries the same heading.
A dedicated setting outranks the dictionary. Where a text is set by its own setting — Title,
Instruction, BrandName, FooterHtml, HopInstructionHtml — the page shows that text, and an entry
for the built-in text it replaces does not apply. The dictionary acts only where the page falls back to
its built-in text.
An entry that never applies is reported, once. An entry for a text outside the list above, and a
replacement whose slots do not match, go into the configuration snapshot report with a warning naming
the level, the record and the configuration key of the entry — at startup and again when the
configuration source reloads, not on every page. The report is also where a reworded built-in text shows
up: the key is the English text, so a text the product rewords gets a new key, and an entry for the old
one finds nothing. After an update, check the report and move such entries — and the values of your
locale files — to the new keys. Among the recently reworded texts: the button of the one-tap email
(Send confirmation email), the status line after it (The confirmation email is open in your mail app. Send it to continue.), the line of a scan (QR code scanned. Continue on your phone. and its variants
listed above) and the message shown when the claims completion form hits the per-address email limit. The page of
the magic link no longer asks Do you confirm the sign-in? with Yes and No, and the launch page on
the phone no longer has a separate button that only copies the spare start code. The host starts regardless and every other entry of
that client applies. If the dictionary cannot be resolved at all (the ui_config store is unreachable),
the page renders with its built-in texts and a warning goes into the log.
Extension points
Key services can be swapped for your own implementation — register them in theAddVeriqaAuthServer block.
Sign-in page renderer
ImplementIAuthPageRenderer for full control over the sign-in page HTML:
AuthPageRenderContext arrives with values that are already resolved: Design is the
effective page design (the global section overridden field by field by the ownership levels),
Title and Instruction are the texts if some level set them (otherwise null, and the text
is yours), and QrVisibility is the effective QR visibility. Your renderer combines nothing on
its own and therefore cannot lose an override.
Three more members arrive resolved the same way, and a renderer of your own has to act on each —
the built-in one does:
ChannelButtonVisibility— the visibility of the channel buttons, already reconciled withQrVisibility: a pair that would leave a device with neither arrives asAlways;ChannelLayout—TabsorGrid;CancelForm— the form behind “Cancel sign-in”, ornullwhen this page offers no such button. Render it as aPOSTform toPathBase+CancelForm.ActionUrlwith two hidden fields:session_idcarryingSessionId, andCancelForm.AntiforgeryFieldNamecarryingCancelForm.AntiforgeryToken.
Title and Instruction arrive as localization keys, not as finished text (see “Texts set
in configuration are localization keys too”). The built-in renderer runs them through the locale files;
your renderer does the same by calling
IConfirmationPromptLocalizer.ResolveOrBaseText(value, language) — otherwise a text set at a
level stops being translated, and a value with a context suffix shows its label.
They also arrive raw, and so does the Hint of a channel: the value may carry
markup, and nothing has checked it. The
built-in renderer checks each value and falls back to plain text; a renderer of your own either
HTML-encodes the value before it goes into the page or checks it by rules of its own. Emitting it
as is puts whatever the setting holds into your page.
Claims mapper
ImplementIClaimsMapper for your own mapping of the resolved identity to OIDC claims:
UseClaimsMapper<T>() registers the mapper as scoped — one instance per authorization request.
That is what lets it take a scoped dependency (DbContext, a unit of work, any per-request service)
straight through the constructor, as IMyDirectory above: no IServiceScopeFactory workaround is needed.
The shipped default stays a singleton — it is stateless and depends on nothing.
ClaimsMappingContext describes who the claims are being built for:
The attribution is what a pairwise subject identifier needs (OIDC Core 1.0 §8.1): the same end user
gets a different
sub per relying party. The built-in mapper does not use it — the sub it issues
is global across relying parties.
Transaction event handler
ImplementITransactionEventHandler to react to transaction events (for example, to link a
notification channel on sign-in):
IEnumerable<ITransactionEventHandler>, so every registered handler receives every
event. The shipped handlers keep running — the sign-in window still gets its completion event, and
the audit trail still gets its records.
Idempotent transaction creation
CreateTransactionRequest accepts the IdempotencyKey + IdempotencyScope pair: a repeated
request carrying the same pair returns the transaction already created instead of starting a second
one (and is refused with idempotency_key_conflict when the creation parameters differ).
Among the built-in entry points, POST /api/transaction/confirmation — the creation of a
confirmation transaction over client credentials — fills the pair in: the key comes from the
idempotency_key field of the request body and the scope from the authenticated client_id, so the
same key from two applications denotes two different requests. A repeat with the same key answers
with the same transaction and a freshly built way in. The browser authorization endpoint does not
fill the pair in: there a repeated request means a new sign-in rather than a retry of the previous
one. If you drive the engine directly, filling the pair in is up to you.
Next steps
Storage
Transaction store and the OpenIddict database: InMemory / EF Core / Redis, PostgreSQL.
Initiator context
What the confirmation shows: who initiated the sign-in and from where.
Preparing for production
The pre-production checklist.
.NET quickstart
The basic integration in a few minutes.