Summary#
An address and a password, done properly once: use Osysharp.UserAccounts@1; gives an app sign-in, sign-up, a forgotten
password, a changed password or address, address verification, two-step sign-in with an authenticator app, and a screen
for each — and wires them into the app's sign-in for it. The app writes its roles and one setting:
[Role] enum AppRole { Authenticator, Agent, Lead } // `Authenticator` is the role the sign-in flow runs as
app.Accounts = new AccountsSetup { AppName = "Acme Support", SignupRole = AppRole.Agent };The kit's pages are served at the kit's routes — the app writes none of them (Pages). With app.OAuthClients
declared, the same pages sign people in with Google or GitHub, and link either to an account (Providers).
Signature#
use Osysharp.UserAccounts@1; // with Osysharp.Identity@0, Osysharp.Mail@0, Osysharp.Sms@0, Osysharp.EmailTemplates@0, Osysharp.Ui
// (it brings Osysharp.Workflow itself — an app writes that `use` only once it names a workflow)
[Role] enum AppRole { Authenticator, … }
app.Accounts = new AccountsSetup {
AppName = "…", // on the screens and in the mail
SignupOpen = true, // false: accounts come from an operator (`osy user add`) or an invitation
SignupRole = AppRole.…, // the role a new account gets; none by default
FirstAccountRole = AppRole.…, // the role the FIRST account also gets — whoever sets the app up runs it
MinPasswordLength = 10,
RequireVerifiedEmail = false, // true: no ticket until the address is proven — and sign-up never says who has an account
AddressProof = new AddressProofPolicy(), // derive to say whose address must be proven first when not everybody's
HomePath = "/",
Mails = new AccountMails(), // derive to write your own mail
Texts = new AccountTexts(), // derive to write your own text messages
ProvePhones = false, // accounts may add a phone number, proven by a texted code
SmsSignIn = false, // …and sign in with a code texted to it
SmsSecondStep = false, // …and choose a texted code as their second step (never one an app can REQUIRE)
Kinds = new AccountKinds(), // derive to make your own kind of account on sign-up
SecondFactor = new SecondFactorPolicy(), // derive to say who MUST use two-step sign-in
Events = new AccountEvents(), // derive to hear of every sign-in (a sign-in history, say)
OwnDoor = new OwnDoorPolicy(), // derive to send a provider sign-in back to the app's own door
LogoUrl = "", // the app's mark on every sign-in screen — File.Url(…) or https
};Provider sign-in needs only what an app declares for OAuth anyway — its clients (app.OAuthClients, capability
Login) and use Osysharp.Oauth; — and the kit offers each as "Continue with …" (Signing in with a provider).
Description#
What the kit does#
| Flow | Function (an [AuthMethod], wired for you) | Page (served for you) | Route |
|---|---|---|---|
| Sign in | SignIn(email, password) | SignInPage | /login |
| Sign up | SignUp(email, password, name) | SignUpPage | /sign-up |
| Ask for a reset link | RequestPasswordReset(email) | ForgotPasswordPage | /forgot-password |
| Choose a new password from the link | ResetPassword(token, password) | ResetPasswordPage | /reset-password/{token} |
| Change the password (signed in) | ChangePassword(email, current, new) | AccountPage | /account |
| Change the address (signed in) | ChangeEmail(email, current, newEmail) | AccountPage | /account |
| Call off a move it did not ask for (from the OLD address's mail) | CancelAddressChange(token) | CancelAddressChangePage | /cancel-address-change/{token} |
| Call off its own pending move (signed in) | CancelMyAddressChange() | AccountPage | /account |
| Open a verification link | VerifyEmail(token, password, newPassword) | VerifyEmailPage | /verify-email/{token} |
| Type the verification mail's code | VerifyEmailCode(email, code, password, newPassword) | SignUpPage · SignInPage (when the address must be proven) | /sign-up · /login |
| Add or change a phone number | ProvePhone(email, password, phone, code) | AccountPage (with ProvePhones) | /account |
| Sign in with a texted code | SignInWithPhone(phone, code) | SignInPage (with SmsSignIn) | /login |
| A texted code as the second step | SetSmsSecondStep(email, password, on, code) | AccountPage (with SmsSecondStep) | /account |
| The code after the password | SignInWithCode(challenge, code) | SignInPage (its second step) | /login |
| Turn two-step sign-in on | EnableSecondFactor(email, password, key, code) | SignInPage (when required) · AccountPage | /login · /account |
| Turn it off | DisableSecondFactor(email, password, code) | AccountPage | /account |
| New recovery codes | NewRecoveryCodes(email, password, code) | AccountPage | /account |
| Ask for a new verification link | RequestVerification(email) | AccountPage (a changed address) · your operator page | — |
| Sign in with a provider | SignInWithProvider(pendingToken) | ProviderSignInPage | /oauth/sign-in |
| Link a provider to the account | LinkProvider(email, password, pendingToken, code) | ProviderSignInPage (signed in) | /oauth/sign-in |
| Unlink one | UnlinkProvider(email, password, provider, code) | AccountPage | /account |
use Osysharp.UserAccounts; states app.AuthBootstrap and app.Auth for the app (auth bootstrap (login, before anyone is signed in)), so every
flow above is reachable by a signed-out visitor and osy user add works — an app that states its own
app.AuthBootstrap replaces the kit's whole.
A flow your own bootstrap leaves out is named. Because yours replaces the kit's whole, a slot you do not write is a
flow nobody wires — its page looks live and every visitor is refused. So the compile warns AUTH_KIT_FLOW_UNWIRED once
for each such slot, naming the kit's function, and the app still compiles. Wire it, or write the slot as null to say
you leave it out on purpose; a null slot wires nothing and is not warned about (Login is never optional):
app.AuthBootstrap = new AuthBootstrap {
Role = AppRole.Authenticator,
Login = SignIn, PasswordResetRequest = RequestPasswordReset, PasswordReset = ResetPassword,
// … every other kit flow you keep …
Signup = null, // the only way in is our own invitation, so the kit's sign-up stays unwired
};What holds by construction#
- The hash is the sign-in flow's alone.
deny read PasswordHash when !IsAuthenticatorand the same for writes, onUser— and, because a package's rules are the floor every type derived from it stands on, on an app'sEmployee : Usertoo. A signed-in person never reads a hash, their own included. - An unknown address costs a hash. Every refusal from
SignInspends one password verification and answers the same"", so neither the answer nor its timing says which addresses have accounts. - Wrong passwords are limited; right ones are not. The platform's
[[security-auth-bootstrap#sign-in-limits|sign-in limits]] hold for
SignIn,ChangePassword,ChangeEmailand every other flow here that checks a password: ten failures a minute from one address, and a growing wait (never a lock) for an address guessed at from anywhere. A right password answered with a two-step challenge counts as a success; the code step has its own limit, below. - A reset link is a mailed secret, stored hashed, working once, for an hour. Pressing the button twice mails once. Using one link spends every other outstanding one.
- An address is proven by a message it received — its link, or the six-digit code beside it. Only token hashes and keyed code fingerprints are stored and only the sign-in flow writes them, so no account can make one whose secret it knows. A change of address takes effect only from the NEW address's link or code; the OLD address is told — and its mail carries a single-use "This wasn't me — cancel the change" link that calls the move off with no password (the asker has it): the new address's link and code then confirm nothing, and every session of the account ends.
- Every one-time code is bounded three ways — a keyed fingerprint (never the code), fifteen minutes, dead at its fifth wrong try — and each wrong code counts against the account's wrong-code limit and the sign-in limits. See below.
- With
RequireVerifiedEmail, no ticket before the address is proven — sign-up and sign-in answerMustVerify. An app that wants it of SOME people (the public, while its own team signs in at once) derivesAddressProofPolicyand overridesRequired(User account), exactly asSecondFactorPolicysays who must use two-step sign-in. - Signing up first with somebody else's address gets you nothing. An account's first proof needs the password it was made with, or hands the account to whoever holds the address. See below.
- Sign-up never says whether an address has an account — the mail does. See below.
- A texted code is never a second factor an app can require. See below.
- One address, one account —
[Unique]across every kind of account, stored trimmed and lower-case. - Sign-up grants only what the app chose (
SignupRole,FirstAccountRole) — never a role the visitor names.SignupRoleis an app-wide role every stranger can get, so it must never be one that manages people or organisations: an organisation's owner is a role held IN it ([[security-roles-held-in#signup]]), andosy lintrefuses the other shape (security-signup-role-administers-everything, MUST). - A password change needs the current password, so a session left open cannot be turned into a new password; the account is told by mail.
- A password change, a reset or a claim signs the account out everywhere else. See below.
- The hash never reaches the audit trail — the platform redacts the field
app.Authnames. - With two-step sign-in on, a password alone signs nobody in — not the sign-in page, not a mailed reset link, not a provider, not the platform's own console sign-in. See below.
- A provider is matched to an account only through an address the account has PROVEN — never by an address alone. See below.
How is an address proven — link or code?#
Every verification mail carries both: a link (it works for three days) and a six-digit code (fifteen minutes).
Either one proves the address and spends the other. The code is for the person still on the page — the kit's sign-up
and sign-in screens ask for it when the app requires a proven address — and VerifyEmailCode(email, code) answers the
step a sign-in would: a ticket, a code from an authenticator, or setting one up. Receiving the mail is proof enough to
sign in, exactly as a reset link is.
A code is kept only as a keyed fingerprint with a nonce of its own (a stolen table cannot be guessed offline), a newer
code for the same thing spends the older, a code dies at its fifth wrong try, and each wrong try counts against the
account's wrong-code limit (the one authenticator codes spend: ten in fifteen minutes and no code is checked) — and,
because a wrong code throws inside an [AuthMethod], against the [[security-auth-bootstrap#sign-in-limits|sign-in
limits]] too. Texted codes (below) are the same mechanism on the other channel.
Can the desk change somebody's address for them?#
Yes — at once, and on the record. A person whose old address is gone cannot open a link sent there, and the desk answers
for the business, so a person the app names calls ChangeAddressFor(account, newAddress, reason) with how they know it
is that person's account ("ID checked at the desk"). The account moves when the change commits. An
AddressChangeRequest keeps the old address, the new one, the reason, who made it ([CreatedBy] MadeBy) and when; the
OLD address is told it moved (AccountMails.AddressChangedFor) and the NEW one is welcomed (AddressMovedHere) —
neither mail asks for anything. A change the person had asked for themselves, to some other address, is spent.
A change a person makes themselves is different and stays so: ChangeEmail needs their password (and their second
step), mails a link and a code to the new address, tells the old one, and moves nothing until the new address confirms
it — an address is where a password is reset to, so whoever knows the password must not be able to move it alone.
Who may make a desk change is the app's to say, and nobody may until it does — ChangesAccountsFor is a
virtual policy whose default is false:
policy ChangesAccountsFor => IsLead;A change is made only by that function (deny create except via ChangeAddressFor — deny … except via), which
checks first: an address that is not one, the account's own, or another account's, and a change with no reason, are
refused with the sentence to show. The desk's own context never writes the address; the change's run does.
An account for somebody who never signs in#
A child a parent books for, a walk-in the desk records, a member a coach keeps a register of: they need an account —
bookings, passes and signatures point at one — and will not sign in. MakeManagedAccount(name) makes it: the app's own
kind of account (AccountKinds.New), named as given, with no password and an address on a domain that cannot receive mail
(…@managed.invalid), so nobody can sign in with it and no mail is ever sent to it. Its record
(ManagedAccountRequest) keeps the name, who made it (MadeBy) and when, and who looks after it — the caller, unless
lookAfterIt: false (a walk-in). ManagedAccountsOf(guardian) lists the accounts a person looks after.
Who may make one is the app's to say, and nobody may until it does — MakesManagedAccounts is a virtual policy whose
default is false:
policy MakesManagedAccounts => IsMember;Later the person can have a login of their own: their guardian (or, for an account made with none, whoever may make
them) calls GiveManagedAccountAnAddress(account, address). A link goes to that address; the account moves there only
when it is opened — the same confirmation every address change takes — and the person then chooses a password with
"forgot password". The record is never written directly (deny create except via MakeManagedAccount); with
Osysharp.Privacy, the managed account is in its guardian's copy of their data and is erased with the person it was made
for (Osysharp.Privacy.UserAccounts).
Who can change an account's address?#
Nobody, directly — not the account itself, not the app's staff, not a client's unit of work or an API call, and not even
the sign-in flow outside one function. User carries
deny update Email except via ConfirmAddressChange;on the address it inherits from Osysharp.Identity's Principal (deny on an inherited field), so the address moves
in exactly two places: ConfirmAddressChange, which runs when the NEW address has opened its link or typed its code
(VerifyEmail / VerifyEmailCode on a change), and the desk's recorded change above (its run is the kit's own code).
Anything else that writes Email on an account — a page that sets it and commits, a request that sends it — is refused,
and the address stays.
What if somebody signed up with my address before I did?#
That is account pre-hijacking: a stranger makes an account with your address and a password of their own, and waits for you to confirm the address. The kit closes it at that moment — the FIRST time an account's own address is proven, on an account that already has a password:
- The link or code alone proves nothing yet.
VerifyEmail(token)/VerifyEmailCode(email, code)answerMustConfirmPassword— nothing proven, nothing spent — and the page asks for one of two things. - The password the account was made with (
VerifyEmail(token, password)): whoever made it is the one proving it — proven, and signed in as a sign-in would be (a code from their authenticator next, if they set one up). - Or a new password (
VerifyEmail(token, "", newPassword)): whoever holds the address takes the account. Every way in set up before the proof is cleared — the old password, two-step sign-in and its recovery codes, a phone and its texted step, every linked provider, every outstanding reset link and challenge — then the new password is set and they are signed in.
The person who signed up is not asked twice. The discriminator is the password, not the mailbox: the mail proves
who HOLDS the address, only the password says whether that is who CHOSE it. So the kit's sign-up and sign-in screens
send the password they just took along with the code (AddressCodeStep(address, then, password: …)) and the maker goes
straight in; only the link page, opened on its own, asks for it — the same password they would type to sign in
afterwards anyway. A reset link that is an account's first proof (ResetPassword) clears the same things, since it
chooses a new password by definition.
What the stranger did while signed in — with RequireVerifiedEmail off they were — stays in the account's data; the
ways in do not. Apps where that matters turn RequireVerifiedEmail on, so nobody is signed in before proving anything.
What happens to the account's other sessions when its password changes?#
They end. An account's sessions are valid from its last change: ChangePassword, ResetPassword, the proof that
moves the account to a new address, and the first proof that claims an account (above) each call
[[stdlib-security#end-other-sessions|Security.EndOtherSessions(account.Id)]], so every ticket the account already
holds — on any browser, on any node — is refused on its next request with "You were signed out because this account's
password was changed or reset in another session. Sign in again.", and any live socket it holds closes.
- The session making the change stays signed in. Changing your password on your laptop signs your phone out, not the laptop.
- A reset, made from a signed-out browser, signs every session out — and the ticket the reset answers, issued after, is the one that works.
- The stranger of a claimed account is signed out at the claim. With
RequireVerifiedEmailoff they were signed in at sign-up, with a ticket good for a day; it stops working the moment the owner proves the address with a new password. The maker who proves it with their OWN password changes nothing about the way in, and stays signed in. - A new address signs the other browsers out when it is proven — the moment the account's way in moves. Asking for the change signs nobody out; the old address keeps working until the new one's link or code is used. Calling it off from the OLD address's link signs every browser out — whoever asked is signed in somewhere, and knows the password.
- A change that does not happen signs nobody out. The sessions end when the change COMMITS: a wrong current password, or any refusal later in the same call, leaves every ticket working.
What does sign-up answer for an address that already has an account?#
Not "there is already an account" — that sentence tells a stranger who has one. What happens depends on
RequireVerifiedEmail:
- On: a taken address is answered exactly as a new one is —
MustVerify, after the same hashing time — and the MAIL says which: a new address is sent its code and link, a taken one is told "you already have an account", with the way to sign in or choose a new password. Nothing on the page, in the answer or in its timing differs. - Off: a new account is signed in at once, and a taken address cannot be — so there, whether an address was free is
visible to whoever tries it, by construction; the kit does not pretend otherwise. What it does: the right password
for the taken address signs its owner in (they signed up with their own account — the check is an ordinary password
check, under the sign-in limits), and any other gets the same
MustVerifystep and the same mail to the owner. An app that must not show which addresses have accounts turnsRequireVerifiedEmailon.
Either way the owner is mailed at most once a minute, and every sign-up that went through counts against the platform's [[security-auth-bootstrap#sign-up-cap|sign-up cap]] — 30 an hour from one address.
Can a phone number sign in, or be a second step?#
When the app keeps phone numbers (ProvePhones, or either option below), an account adds one on the account page and
proves it with a texted code (ProvePhone); only the sign-in flow writes the number, so no account can claim one it does
not hold, and nobody reads another account's number. The texts go through The Sms kit — send a text message, keep a record, pick a carrier — with no sender, the codes are
held in its outbox and nothing is sent. Two uses, each off until the app turns it on:
SmsSignIn— a proven number signs in with a texted code in place of the password (SignInWithPhone), answered alike whether or not the number has an account; texted at most once a minute and five times an hour. Then the same step as after a password: a code from an authenticator when that is on. Off by default because whoever takes over a number (a SIM swap, a port-out) could then sign in with it — choose it where that trade is right for the app's people. A number more than one account could sign in with signs in NONE of them: no code is sent — the phone is texted to sign in with an address and password instead — and the caller is answered exactly as for any other number.SmsSecondStep— an account may choose a texted code after its password (SetSmsSecondStep, which takes a code from the phone to turn off). Such an account is not signed in by the phone alone: one phone is one factor.
⛔ A texted code is NOT a second factor an app can require. SecondFactor.Required always means an authenticator
app: an account the app requires one of is taken through setting one up whether or not it chose texted codes, and
SignInWithCode refuses a texted code for it (NIST 800-63B lists SMS as "restricted").
Which roles does an account get?#
The roles are the app's own [Role] enum, and it must have an Authenticator member — the role the sign-in flow runs
as, which the kit names as role.Authenticator (role — a package's name for the app's own roles). The kit keeps grants in
Osysharp.Identity's RoleGrant; write the app's own role policies over it:
policy IsLead => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Lead);A new account gets the role sign-up gives it; everything after that — a manager giving staff their roles and taking
them back, with a record of who changed what — is Osysharp.UserManagement, the user manager. An app with more than one role
usually adds it (osy compose and osy lock say so): The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it.
An app's own kind of account#
Derive from User for what the app knows about its people, and tell sign-up to make that type:
entity Employee : User {
Department Department = Department.Support;
security { // its own grants, inside the kit's floor
allow read when IsAuthenticated || IsAuthenticator;
allow create when IsAuthenticator;
allow update when IsAuthenticated || IsAuthenticator where Id == user.Id;
allow update when IsAuthenticator;
}
}
public class StaffKinds : AccountKinds {
public override User New(string email, string name) { return new Employee { Email = email }; }
}
app.Accounts = new AccountsSetup { …, Kinds = new StaffKinds() };user is the base Principal in every rule; user is Employee tells the kinds apart.
How does two-step sign-in work?#
After the password, a six-digit code from an authenticator app on the person's phone (Google Authenticator, Microsoft Authenticator, 1Password — any app that speaks TOTP); or, when the phone is lost, one of ten one-time recovery codes. Anybody may turn it on from the account page; an app says who must:
public class AdminsUseCodes : SecondFactorPolicy {
public override bool Required(User account) {
return RoleGrant.Any(g => g.Member == account && (g.Role == AppRole.Admin || g.Role == AppRole.Lead));
}
}
app.Accounts = new AccountsSetup { …, SecondFactor = new AdminsUseCodes() };An account that must, and has not set it up, is taken through setting it up at sign-in (and at sign-up) — it is given no ticket until it has, so there is no page it reaches first. The kit's sign-in page does all of this in place.
What holds, by construction:
- No ticket before the second factor. For an account with it on,
SignInanswers a challenge: kept only as a hash, good for five minutes, used once, and dead after five wrong codes.SignInWithCode(challenge, code)issues the ticket. A mailed reset link sets the password and signs nobody in; the platform's password-only sign-in refuses the account (SecondFactorField, which the kit declares). - A code works once. The step of the last code accepted is kept, and a code for it or an earlier step is refused — so a code read over a shoulder is useless the moment it has been used.
- Guessing is bounded. Ten wrong codes on one account in fifteen minutes and no code is checked for it — the right one included — until they age out. A fresh challenge per guess does not escape it.
- The secret is nobody's but the sign-in flow's — the account's own included once it is on — and is stored sealed
with the app's own key (
Crypto.Encrypt), so a backup or an operator's query is not a factor either. - Recovery codes are kept as keyed fingerprints, work once, and a new set spends the old one. Using one mails the owner, as does turning the factor on or off.
- Every change takes the password AND a current code: turning it off, new recovery codes — and, while it is on, a new password or address. A password alone never removes the factor that exists because a password is not enough.
Setting it up at sign-in needs no second password. MustEnrol comes with an Enrolment token — kept as a hash,
good for fifteen minutes, used once, refused as a code challenge — that EnableSecondFactor(…, enrolment: token) takes
in place of the password. That is what lets an account that signed in with a PROVIDER, which typed no password, set the
factor up; the kit's screens pass it for you.
A lost phone is an administrator's job. ClearSecondFactor(account) turns the factor off — the authenticator, the
unused recovery codes, a texted step and any waiting challenge — and mails the person. Call it from an audited path:
Osysharp.UserManagement's ResetSecondFactor(person, reason) records who did it and why, and signs the
person out everywhere. An app that requires the factor takes them through setting it up again at their next sign-in.
A sign-in page of your own hosts SignInForm — it carries every extra step — or calls the flow itself: SignIn,
SignUp, SignInWithProvider, VerifyEmail, VerifyEmailCode and SignInWithPhone answer a SignInStep (Ticket;
or Challenge with ChallengeBy — "app" or "sms"; or MustEnrol with its Enrolment; or MustVerify, the address
to prove; or MustConfirmPassword, an account's first proof; or Proven, a link that needs nothing
more; or CodeSent; or — for a provider — NoAccount), never a bare ticket.
The console signs in with the code too. osyrin login (and every CLI sign-in to the app) runs the app's own
SignIn and then asks for the code — or takes --code — because the kit wires SecondFactorSignIn; an account that
must set a factor up is told to do so in a browser first ([[security-auth-bootstrap#second-factor]]).
Signing in with a provider#
Declare the providers as any app does, and the kit's sign-in form offers each one:
use Osysharp.Oauth; // in the manifest
app.Secrets = [ new Secret("GoogleClientSecret") ];
app.OAuthClients = [
new OAuthClient("Google") {
Provider = OAuthProvider.Google, Capabilities = [OAuthCapability.Login],
ClientId = "…apps.googleusercontent.com", ClientSecret = Secret.GoogleClientSecret,
Scopes = ["openid", "email", "profile"],
},
];A provider is offered only once it can sign somebody in on this installation: its ClientId is set and its
ClientSecret has a value (osy secret set GoogleClientSecret locally). One declared ahead of that — no client id yet,
or a secret with no value — draws no button, and the platform's /api/oauth/authorize refuses it, naming what is
missing. OfferedSignInProviders() is what every page reads, so a test states what is offered with
OfferedSignInProviders.Stub(() => new List<string> { "Google" }) rather than depending on whose secrets it runs with.
"Continue with Google" goes to the provider and back to the platform, which verifies who the provider says this is and
hands it — sealed, so a browser cannot forge it — to the kit's /oauth/sign-in page. The platform signs nobody in by
itself: the kit wires the OAuthSignIn slot, and for an app that does, the callback resolves no account and issues no
ticket ([[security-auth-bootstrap#oauth-sign-in]]). SignInWithProvider then decides, in this order:
- An identity already linked to an account signs in to that account.
- Otherwise an account whose address the provider verified and the account itself has proven is linked, told by mail, and signs in. An account whose address nobody has proven is not handed over — it is mailed a link to prove it, and the person is told to sign in with the password or come back once proven.
- Otherwise, when sign-up is open, an account is made for the address — proven already, with no password, with the
same roles a password sign-up gets; when it is closed, the answer is
NoAccount.
Whichever account it is, the rest is the same as after a password: a code when two-step sign-in is on, or setting it up first when the app requires it.
Each button shows its provider's mark — GitHub, Google and Microsoft built in, any other the app's choice
(AccountsSetup.Marks, ProviderIcon — the mark on every "Continue with …" and "Link …" button).
Linking and unlinking are on the account page — "Link Google" goes to the provider and back, and the page then asks
for the current password (and a code, with two-step sign-in on) before LinkProvider attaches it; UnlinkProvider
takes the password too. A borrowed session therefore cannot add a way in of its own, and an account can never unlink
its last way in (unlinking needs its password). An account that signed up with a provider has no password; it gets one
from "Forgot your password?", which mails a link to its proven address.
An app with a door of its own — an application form for strangers — takes the provider page over and hosts
ProviderSignIn(ownDoor: true) { YourForm(); }: on NoAccount (or OwnDoor, below) the slot is shown, and the form
reads the same sealed token from the address and calls the app's own [AuthMethod] (the OAuthSignup slot), which ends
in ContinueSignIn.
An app's own door: making the account, proving a phone, coming back#
An app that lets strangers in only through a door of its own — an application to a waitlist, say — keeps SignupOpen
false and builds the door from four pieces, each the kit's own rule rather than a copy of it:
| Piece | What it does |
|---|---|
SignUpAtDoor(email, password, name) | SignUp without asking SignupOpen: a new address gets an account and its proof; a taken one is answered exactly alike and its mail says so. Called from the app's own [AuthMethod], wired in the Signup slot so it is capped like every sign-up |
TextPhoneCode(account, phone) | texts a code that proves phone for an account the door has already established — a provider's sealed sign-in, an application it is finishing — where ProvePhone would ask for a password the account may not have. Answers the number as kept (E.164) |
ProvePhoneCode(account, phone, code) | records the number as the account's, proven by that code; a wrong one is a wrong try, kept |
AccountsSetup.OwnDoor | an OwnDoorPolicy whose Unfinished(account) says the door has not finished with this account. A provider sign-in for it answers SignInStep.OwnDoor and no ticket, so the provider page shows the door again instead of signing in |
public class ApplicationsInProgress : OwnDoorPolicy {
public override bool Unfinished(User account) => Application.Any(a => a.Account == account && a.Waiting);
}
app.Accounts = new AccountsSetup { …, SignupOpen = false, ProvePhones = true, OwnDoor = new ApplicationsInProgress() };One phone, several accounts. A number is not one account's: a person with more than one account proves the same
number on each, every time with a code texted for THAT account — a code texted to the number for one account is a
wrong code in another's proof. So TextPhoneCode texts a number other accounts hold like any other, and an app that
needs to count people must not count them by phone. Where a number would IDENTIFY an account — signing in by text — a
shared one identifies nobody (see SmsSignIn above).
What does the app hear about each sign-in?#
AccountsSetup.Events hears every completed sign-in — a password, a code, a provider, a sign-up, a reset link — once,
as the sign-in flow, so it may write what only that flow may:
public class SignInHistory : AccountEvents {
public override void SignedIn(User account, DateTime at) { new SignInDay { Account = account, At = at }; }
}
app.Accounts = new AccountsSetup { …, Events = new SignInHistory() };An app's own door (an invitation, an application) ends in ContinueSignIn(account) — the same step the kit takes after
a password — so the second factor and Events hold on it too.
The app's mark on the screens#
LogoUrl puts the app's logo above its name on every sign-in screen, with no page taken over — an address the browser
fetches: a file the app keeps (File.Url("public/brand/logo.svg")) or an https address. An app page that frames a form
itself passes AccountsFrame(heading, logo: …). (A data: address is not drawn — no page draws one.)
Where are the pages?#
At the kit's routes, served by the kit — the mail links to these addresses, and /login is where a signed-out visitor
is sent. Each is authorized as it declares: the six a signed-out visitor needs (the five above and /oauth/sign-in) are [AllowAnonymous], /account
requires a signed-in person. Every colour, face and size is a theme token, so the app's theme restyles them.
To use a page of your own, take the kit's over — declare a component of the same NAME. With a [Route] it is served
there instead (at the kit's route, or another); without one, the address is not served. The screens (SignInScreen, …)
and the bare forms (SignInForm, SignUpForm, …) are what your page renders:
[Route("/login")] [AllowAnonymous] [Render(CSR)] // takes the kit's SignInPage over
component SignInPage() { render { MyFrame { SignInForm(); } } }
[Route("/me")] [Render(CSR)] // moves the account page to /me
component AccountPage() { render { AccountScreen(); } }An app page on one of these routes under ANOTHER name is refused, naming both — see routes and pages.
What happens to the mail and texts it sends?#
Reset, verification and notice mail go through The Mail kit — send a message, keep a record, pick a sender's outbox, and texts through The Sms kit — send a text message, keep a record, pick a carrier's. With no
sender (app.Mail, app.Sms), they are HELD — kept, with the reason — and the app still signs people up. Override
AccountMails and AccountTexts to write your own words.
Erasing an account#
EraseAccount(account) is what a person's right to erasure asks of the sign-in kit, done by the kit that knows every
table it keeps about them. The row stays — work it did still names it — and everything that says who it was goes: the
address (left as ErasedAddress(account), on a domain that cannot receive mail), the name, the password, two-step
sign-in, a phone, every linked provider, every role, every session, every link and code ever sent to it, and the
outbox's copies of that mail (Osysharp.Mail's EraseMailTo). The account is left inactive.
It decides WHAT erasing means, never WHETHER somebody may be erased: call it from the run that erases — a workflow body, as the system (an app's own erasure run, or a kit built on this one, as Osysharp.Shop's is). Called from a request it writes as its caller and the kit's own rules refuse it.
What this version does not do yet#
Passkeys as a second factor (on the same challenge and the same SecondFactor* slots). Invitations, organisations and
the user manager are kits of their own, built on this one: The Invitations kit — invite an address with a role, answered only by that address, The Organizations kit — companies, teams, and roles held within one,
The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it.
Step-up: prove it is you, now#
StartStepUp(CodePurpose.PrivacyRequest) mails a six-digit code to the signed-in account's own address;
ProveStepUp(check, code) takes it back; SpendStepUp(check, account, purpose, within) lets the action's own run (as
the system) spend a fresh proof once. Only the account's own person makes or proves one, and the code is the kit's
one-time code (fingerprinted, fifteen minutes, five tries, counted against the account). A session somebody left open
cannot take the action on its own. Osysharp.Privacy.UserAccounts uses it for a person's own privacy request.
Examples#
A desk whose people are employees#
app Desk {
use Osysharp.Ui;
use Osysharp.Identity;
use Osysharp.Mail;
use Osysharp.UserAccounts;
use Osysharp.EmailTemplates;
model "model/**/*.osy";
}
using Osysharp.Identity;
using Osysharp.UserAccounts;
[Role] enum AppRole { Authenticator, Agent, Lead }
app.Accounts = new AccountsSetup {
AppName = "Acme Support", SignupRole = AppRole.Agent, FirstAccountRole = AppRole.Lead, Kinds = new DeskKinds(),
SecondFactor = new LeadsUseCodes(), // leads must use two-step sign-in
};
public class LeadsUseCodes : SecondFactorPolicy {
public override bool Required(User account) { return RoleGrant.Any(g => g.Member == account && g.Role == AppRole.Lead); }
}
public class DeskKinds : AccountKinds {
public override User New(string email, string name) { return new Employee { Email = email, Team = "Support" }; }
}
entity Employee : User {
[MaxLength(60)] string Team = "";
security {
allow read when IsAuthenticated || IsAuthenticator;
allow create when IsAuthenticator;
allow update when IsAuthenticated || IsAuthenticator where Id == user.Id;
allow update when IsAuthenticator;
}
}
policy IsLead => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Lead);
[Route("/")] component Home() { render { Text("The desk"); } }See also#
- The Identity kit — who signs in, and which of the app's roles they hold —
PrincipalandRoleGrant, which this kit builds on - The Mail kit — send a message, keep a record, pick a sender — the outbox its mail goes through
role— a package's name for the app's own roles — how a package names the app's roles- auth bootstrap (login, before anyone is signed in) — the sign-in wiring the kit states for the app
- entity Sub : Base — deriving
Employee : User, and the package floor it stands on - routes and pages — how a package's pages are served, and taken over
- Totp.NewSecret, Totp.Uri, Totp.Verify, Totp.Code — the authenticator codes two-step sign-in is built on
- Passkeys — sign in with the device, no password and no code — passkeys: sign in with the device, several per account, password sign-in off
- The Sms kit — send a text message, keep a record, pick a carrier — the outbox its texted codes go through
- OAuth account completion — Security.VerifyPendingOAuthEmail / LinkOAuthFromPending — the sealed provider identity the provider sign-in reads
- ProviderIcon — the mark on every "Continue with …" and "Link …" button — the mark on each provider's buttons, and giving another provider one
- The Invitations kit — invite an address with a role, answered only by that address — bringing a person in by invitation, with sign-up closed
- The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it — the user manager: roles, deactivation, a lost second factor, sign-out everywhere