Starting and ending a session
Magic links, passkeys and providers start the session themselves (Checking the person). Jadpo has no password sign-in.
When your app checks a person some other way, such as someone accepting
an invitation it sent, the action that checks them starts the session
with sessions.start. sessions.end signs someone out. Jadpo sets and
clears the cookie and its CSRF token on the route's response:
action accept_invitation(token: InvitationToken) fails InvitationExpired -> Welcome { var invitation: Invitation = attempt Invitation.redeem(token) sessions.start(invitation.member_id) ...}action sign_out() -> Unit { sessions.end()}
sessions.start(invitation.member_id) starts a session for that member,
and the response of the route that ran the action sets its cookie.
sessions.end() ends the session the request came with.
sessions.startkeeps the session in the action's own transaction, in thejadpo_sessionssystem table of the store the principal's user entity lives in (System tables). So an action that fails starts no session. The route's response sets the cookies once the action commits.sessions.enddeletes the session the request came with, and the response clears the cookies.- A session or token past its end, which a lookup ignores, and a service key past its expiry, are deleted on the tick, Jadpo's regular background sweep, in the store each is kept in (Jobs and events).
- Only an action calls them (
type.auth_sessions_context). A test reaches them through the route, or calls the action, which keeps the session without a response to set it on. - Only verifiers are kept: a session's token and its CSRF token are 32 random bytes each, stored as their digests, so a copy of the database signs no one in.
API tokens
Your own mobile and command-line clients sign in with an API token. An action issues one once it has checked the person, and returns it in its output. That is the one time anyone sees it:
action accept_invitation_in_app(token: InvitationToken) fails InvitationExpired -> AppSignedIn { var invitation: Invitation = attempt Invitation.redeem(token) return AppSignedIn { token: sessions.issue_token(invitation.member_id) }}
sessions.issue_token gives an ApiToken, which the client keeps and
sends as Authorization: Bearer. With magic links, the generated
POST /auth/magic-link/redeem trades a code for a token instead
(Magic links).
sessions.issue_token(user_id)takes the user entity'sidand gives anApiToken, text anOutputcan send. The token is a session without a cookie: kept as a verifier injadpo_sessionsin the action's transaction, lasting 30 days from when it is issued, with no CSRF token, since a browser never sends it by itself. The response that carries it is never kept for an idempotent repeat, and anApiTokencan't be stored in an entity.sessions.end()ends the token a request came with, as it ends a browser session.sessions.tokens(user_id)lists the user's tokens that haven't ended, oldest first, asIssuedTokens, which code readsid,started_atandends_atfrom;sessions.revoke_token(token_id)ends one by itsid, and anidno token has ends nothing. Together they let an application end a user's other devices.- Like
sessions.start, only an action calls them, except that aGETroute can runsessions.tokensitself (below). Each needsapi_token:inauthentication { }.
Listing from a GET route
An application's "signed-in devices" screen is one route. A GET runs
sessions.tokens(user_id), and Service.keys(service_id) for a service's
keys, directly, as generated queries that read what Jadpo keeps:
routes Devices { prefix: /devices GET / { output: List<IssuedToken> run: sessions.tokens(current_principal.user.id) }}
run:passes the holder'sid, from a placeholder or fromcurrent_principal, and nothing else; the route'soutput:isList<IssuedToken>(List<IssuedKey>for keys), exactly what the listing returns. Any other method isroute.run_kind, and the writes (sessions.issue_token,.revoke_token,issue_key,revoke_key) areroute.run_invalid: an action calls those.IssuedTokenandIssuedKeyare built-in values anOutputcan hold (tokens: List<IssuedToken>) and a route can send. On the wire each is an object of itsid,started_atandends_at, never the credential; the API record and OpenAPI give it a section of its own.- The list goes through Policy like any query's. It holds
the tokens or keys of a holder whose record the caller may
read, and is empty for any other. - A route that lists a holder nobody may read is
policy.effect_ungranted. One anyone may call needsAccess.publicon the holder'sread:.
Signing in, end to end
A whole app that signs members up, keeps their notes and refuses a
suspended member. To show sessions on their own, signing up here starts a
session for a handle without checking the person. A real app checks the
person with sign_in:. The principal and
its one way in:
use journal/entities { Member }use journal/types { MemberStatus }application {}principal { user: { subject: Member.handle active: Member.status == MemberStatus.active }}
authentication { browser_session: { cookie: "notes_session" }}
Signing up checks the handle, starts a session and answers with no content; its response sets the cookies. Writing a note takes the signed-in member as a parameter:
use journal/entities { Member }use journal/types { Handle }use journal/failures { HandleTaken }use journal/actions { register }/// What signing up takes.type SignUp = Input { handle: Handle}/// Registers a member and signs them in.action sign_up(input: SignUp) fails HandleTaken -> Unit { var member: Member = attempt register(input.handle) sessions.start(member.id)}
/// Ends the session the request came with.action sign_out() -> Unit { sessions.end()}
use journal/entities { Note }use journal/types { NoteText, NoteView }/// What writing a note takes.type WriteNote = Input { text: NoteText}/// Writes a note as the signed-in member.action write_note(input: WriteNote, author: Principal.user) -> NoteView { var note: Note = Note.create { set: { author_id: author.id text: input.text } } return NoteView { text: note.text }}
Signing up is the one route anyone may call; every other needs the session:
use journal/actions { SignUp, sign_out, sign_up }/// Signing up and out.routes Account { prefix: /account /// Anyone may sign up; the response sets the session's cookies. POST /sign-up { authentication: RouteAuthentication.none input: SignUp status: SuccessStatus.no_content run: sign_up(input) } /// Ends the session and clears its cookies. POST /sign-out { status: SuccessStatus.no_content run: sign_out() }}
use journal/types { NoteView }use journal/actions { WriteNote, write_note }/// The signed-in member's notes.routes Notes { prefix: /notes POST / { input: WriteNote output: NoteView status: SuccessStatus.created run: write_note(input, current_principal.user) }}
A route test signs in with as: through the real browser session (a session
kept for the member, its cookie, its CSRF token and an Origin), so a
suspended member is refused exactly as a browser would be:
use journal/entities { Member }use journal/actions { WriteNote, register, suspend }test "a note needs someone signed in" { var response: Response = request { method: HttpMethod.post path: "/notes" input: WriteNote { text: "Buy milk" } } assert response.status == 401 assert response.code == "authentication_required"}test "a signed-in member writes a note" { var member: Member = attempt register("ann") var response: Response = request { method: HttpMethod.post path: "/notes" as: Principal.user(member) input: WriteNote { text: "Buy milk" } } assert response.status == 201}test "a suspended member is refused" { var member: Member = attempt register("bob") var suspended: Member = attempt suspend(member.id) var response: Response = request { method: HttpMethod.post path: "/notes" as: Principal.user(suspended) input: WriteNote { text: "Buy milk" } } assert response.status == 403 assert response.code == "user_disabled"}
Tokens and keys, end to end
The same notes, written from the members' app with an API token, and a partner system that reads itself with a service key. Both kinds of caller are declared, and both bearer methods:
use journal/entities { Member, Partner }use journal/types { MemberStatus, PartnerStatus }application {}principal { user: { subject: Member.handle active: Member.status == MemberStatus.active } service: { subject: Partner.name active: Partner.status == PartnerStatus.active }}
authentication { api_token: {} service_key: {}}
use journal/entities { Member }use journal/types { PartnerName, PartnerStatus }/// Another system that reads the journal with its keys, managed by the/// member who added it.entity Partner { id: Uuid owner_id: Member.id { on_delete: OnDelete.restricted } name: PartnerName { unique: true } status: PartnerStatus policy: { create: [owner_id] read: [id, owner_id] update: [owner_id] }}
Signing up issues the app's token and answers with it, once; ending every device lists the member's tokens and revokes each:
use journal/entities { Member }use journal/types { Handle }use journal/failures { HandleTaken }use journal/actions { register }/// What signing up takes.type SignUp = Input { handle: Handle}/// The app's token, which it sends back as `Authorization: Bearer`.type SignedUp = Output { token: ApiToken}/// Registers a member and gives their app a token, shown once.action sign_up(input: SignUp) fails HandleTaken -> SignedUp { var member: Member = attempt register(input.handle) return SignedUp { token: sessions.issue_token(member.id) }}
/// Ends every token the member's apps hold, this one's too.action sign_out_everywhere(member: Principal.user) -> Unit { for token in sessions.tokens(member.id) { sessions.revoke_token(token.id) }}
Adding a partner issues its first key, which expires when the input says; the partner reads itself with it. The member who adds a partner owns it, so its grants let them issue and revoke its keys (Grants):
use journal/entities { Partner }use journal/types { PartnerName }use journal/failures { PartnerNameTaken }use journal/actions { create_partner }/// What adding a partner takes.type AddPartner = Input { name: PartnerName key_expires_at: Instant}/// The partner's key, which it sends as `Authorization: Bearer`.type PartnerAdded = Output { key: ServiceKey}/// Adds a partner and gives its first key, shown once.action add_partner( input: AddPartner, owner: Principal.user) fails PartnerNameTaken -> PartnerAdded { var partner: Partner = attempt create_partner(input.name, owner) return PartnerAdded { key: Partner.issue_key(partner.id, input.key_expires_at) }}
use journal/entities { Partner, PartnerNotFound }use journal/types { PartnerView }/// The partner a key belongs to.query this_partner(partner: Principal.service) fails PartnerNotFound -> PartnerView { from: Partner where: Partner.id == partner.id missing: PartnerNotFound}
use journal/actions { SignUp, SignedUp, sign_out, sign_out_everywhere, sign_up }/// Signing up and out, from the app.routes Account { prefix: /account /// Anyone may sign up; the response holds the app's token. POST /sign-up { authentication: RouteAuthentication.none input: SignUp output: SignedUp status: SuccessStatus.created run: sign_up(input) } /// Ends the token the request came with. POST /sign-out { status: SuccessStatus.no_content run: sign_out() } /// Ends every token the member's apps hold. POST /sign-out-everywhere { status: SuccessStatus.no_content run: sign_out_everywhere(current_principal.user) }}
A member's own devices are one GET route, and so are another member's,
for whoever may read their record. The list is limited the way a query's
records are (Policy), so it is empty for a member the
caller may not read. Member grants a read of itself (read: [id]):
use journal/entities { Member }/// The devices that hold a token for a member.routes Devices { prefix: /devices /// The signed-in member's own, oldest first. GET / { output: List<IssuedToken> run: sessions.tokens(current_principal.user.id) } /// Another member's: a list holds only the tokens of members the /// caller may read, so it is empty for anyone else's. GET /{member_id: Member.id} { output: List<IssuedToken> run: sessions.tokens(member_id) }}
use journal/entities { Partner }use journal/types { PartnerView }use journal/actions { AddPartner, PartnerAdded, add_partner }use journal/queries { this_partner }/// Partners and their keys.routes Partners { prefix: /partners /// A signed-in member adds a partner; the response holds its key. POST / { input: AddPartner output: PartnerAdded status: SuccessStatus.created run: add_partner(input, current_principal.user) } /// A partner reads itself, signed in with its key. GET /me { output: PartnerView run: this_partner(current_principal.service) } /// The keys a partner holds that haven't expired, for the member who /// owns it. GET /{partner_id: Partner.id}/keys { output: List<IssuedKey> run: Partner.keys(partner_id) }}
A route test signs in with as: through the method the route's API
accepts: a member with an API token here, since nothing declares browser
sessions, and a partner with a key from Partner.issue_key, each kept for
real and sent as Authorization: Bearer:
use journal/entities { Member, Partner }use journal/types { PartnerView }use journal/actions { WriteNote, create_partner, register, suspend_partner }test "a member writes a note with the app's token" { var member: Member = attempt register("ann") var response: Response = request { method: HttpMethod.post path: "/notes" as: Principal.user(member) input: WriteNote { text: "Buy milk" } } assert response.status == 201}test "a partner reads itself with its key" { var ann: Member = attempt register("ann") var partner: Partner = attempt create_partner("Acme", Principal.user(ann)) var response: Response = request { method: HttpMethod.get path: "/partners/me" as: Principal.service(partner) } assert response.status == 200 assert response.output == PartnerView { name: "Acme" }}test "a partner can't write a member's note" { var ann: Member = attempt register("ann") var partner: Partner = attempt create_partner("Acme", Principal.user(ann)) var response: Response = request { method: HttpMethod.post path: "/notes" as: Principal.service(partner) input: WriteNote { text: "Buy milk" } } assert response.status == 403 assert response.code == "not_permitted"}test "a suspended partner is refused" { var ann: Member = attempt register("ann") var partner: Partner = attempt create_partner("Acme", Principal.user(ann)) var suspended: Partner = attempt suspend_partner(partner.id) var response: Response = request { method: HttpMethod.get path: "/partners/me" as: Principal.service(suspended) } assert response.status == 403 assert response.code == "service_disabled"}
A route test lists as each member and partner would: a signed-in caller's request keeps a real token or key, so there is one to list, and policy decides whose:
use journal/entities { Member, Partner }use journal/actions { create_partner, register }test "a member lists the devices that hold a token" { var ann: Member = attempt register("ann") var response: Response = request { method: HttpMethod.get path: "/devices" as: Principal.user(ann) } assert response.status == 200 assert response.output != []}test "a member's tokens are listed to no one else" { var ann: Member = attempt register("ann") var bob: Member = attempt register("bob") var by_bob: Response = request { method: HttpMethod.get path: "/devices" as: Principal.user(bob) } assert by_bob.output != [] var by_ann: Response = request { method: HttpMethod.get path: "/devices/{member_id}" params: { member_id: ann.id } as: Principal.user(ann) } assert by_ann.output != [] var ann_on_bob: Response = request { method: HttpMethod.get path: "/devices/{member_id}" params: { member_id: bob.id } as: Principal.user(ann) } assert ann_on_bob.status == 200 assert ann_on_bob.output == []}test "a partner's keys are listed to the partner and its owner" { var ann: Member = attempt register("ann") var bob: Member = attempt register("bob") var partner: Partner = attempt create_partner("Acme", Principal.user(ann)) var by_bob: Response = request { method: HttpMethod.get path: "/devices" as: Principal.user(bob) } assert by_bob.output != [] var by_partner: Response = request { method: HttpMethod.get path: "/partners/{partner_id}/keys" params: { partner_id: partner.id } as: Principal.service(partner) } assert by_partner.output != [] var by_owner: Response = request { method: HttpMethod.get path: "/partners/{partner_id}/keys" params: { partner_id: partner.id } as: Principal.user(ann) } assert by_owner.output != [] var by_member: Response = request { method: HttpMethod.get path: "/partners/{partner_id}/keys" params: { partner_id: partner.id } as: Principal.user(bob) } assert by_member.output == []}
Markdown for agents: /docs/starting-and-ending-a-session.md.Built from Jadpo main at a707efb.