Account and billing
Signing in
Ahena's dashboard (app.ahena.io) supports three sign-in methods: email and password,
GitHub and Google. One Ahena user can have any combination of them.
Ahena user ── password (auth_identities: provider=password)
├─ GitHub (provider=github, provider_subject = GitHub user id)
└─ Google (provider=google, provider_subject = Google "sub")
auth_identities (migration 0007_auth_identities, additive) records:
- the provider and the provider's stable account id;
- the provider's email and whether the provider verified it;
created_atandlast_used_at.
A provider account belongs to exactly one Ahena user, and is never reassigned. Each user has
at most one identity per provider. Every existing account got a password identity when the
migration ran.
Account linking
| Situation | What happens |
|---|---|
| Provider account already known | Signs in to its Ahena user, even if the provider's email changed since (the provider's account id decides, not the email) |
| New provider account, verified email, no Ahena account with that email | A new Ahena account is created, with the email marked verified and no password |
| New provider account, verified email, an Ahena account without a password whose every sign-in method is a GitHub/Google account that verified the same email | Linked automatically |
| New provider account, verified email, an Ahena account with a password and a verified email | Never linked automatically. The callback returns link_required and a single-use link token (10 minutes, held in an HttpOnly cookie by the dashboard); the sign-in form asks for the account's password once, and signing in with it (POST /v1/auth/login with linkToken, or the two-factor step after it) connects the provider (audited as auth.identity_link_pending, then auth.identity_linked with via: "password_confirmation"). After that the provider signs in directly |
| New provider account, verified email, an Ahena account reachable only through other sign-in methods (e.g. a GitHub account with a different email) | Refused (conflict, details.reason: "link_by_sign_in"): sign in the usual way, then connect the provider under Account |
| New provider account, verified email, an Ahena account whose email was never verified (a password sign-up) | The verified email wins. The provider is connected and you're signed in. Everything the registrant set up is removed, in one transaction: the password and every other sign-in method (including a GitHub/Google account they connected), every session and token of every kind (web, CLI, agent, CI), approved-but-unused CLI device codes, open two-factor challenges, two-factor itself, and every organization membership (each audited as member.removed with reason: "unverified_account_claimed" in that organization's log; organizations are kept, and one left without an owner is marked organizationLeftWithoutOwner: true). The claim is audited as auth.unverified_account_claimed with counts. Add a new password under Account if you want one |
| Unverified email, or no email from the provider | Never linked. A new account isn't created either |
| Signed in, "Connect" under Account | That provider account is linked to you, unless it already belongs to another Ahena user |
This prevents pre-registration takeover. Someone could sign up with a password using your email address before you ever use Ahena. Ahena never verified that email, so the registration proves nothing; when you later continue with Google or GitHub, which do prove you own the address, the account becomes yours and the registrant's password, sessions and organization memberships go (you start with no organizations, so nothing the registrant shared with their own other accounts reaches you).
It also prevents verify-then-link takeover. The registrant can have you verify the address (you click the link Ahena emailed you). A verified email proves who reads the mailbox, not who chose the password, so a provider sign-in never joins a password account by email alone: only the password holder can connect it. If someone registered your address and you verified it, the account isn't yours; continuing with Google or GitHub asks for a password you don't have, and your provider identity is never attached to their account.
Sign-in methods can be removed under Account, but never the last usable one. Adding a password, connecting or disconnecting a provider needs a sign-in within the last 15 minutes. Changing a password needs the current one, and signs out every other session.
A disabled account (users.disabled_at) can't sign in by any method, and its existing
sessions stop working. Deleting a user deletes its identities.
OAuth security controls
| Control | How |
|---|---|
| Authorization Code flow | Both providers; the code is exchanged server-side by the API |
| PKCE | S256 for GitHub and Google; the verifier never leaves the API |
| State | 256-bit random, stored only as a SHA-256 hash, single-use (consumed atomically), 10-minute lifetime, checked against the provider |
| Browser binding (CSRF / login CSRF) | The dashboard sets a random value in an HttpOnly cookie (ahena_oauth, path /auth/oauth, SameSite=Lax, Secure, 10 min) when sign-in starts. The callback must present it; another browser can't finish someone else's sign-in |
| Nonce (Google) | Random per attempt; the ID token's nonce must match |
| ID token (Google) | Received directly from Google's token endpoint over TLS (OpenID Connect Core §3.1.3.7); iss, aud, exp, nonce and sub are checked, and email_verified decides linking |
| GitHub email | Only GitHub's verified-email list (/user/emails, the primary email), never the public profile field |
| Exact callback | redirect_uri comes from configuration, never from the request, and is repeated in the token exchange; the providers also enforce their registered value |
| Redirect allowlist | After sign-in, only same-site relative paths (safeNext); the start route only redirects to github.com or accounts.google.com |
| Session | New random session token after every sign-in. The browser's previous session is revoked (rotation). HttpOnly, Secure, SameSite=Lax cookie on app.ahena.io only |
| Errors | Denied, expired, replayed, unknown or wrong-browser state, and provider failures each have a reason code and a plain message. Provider error text is never shown, and each failure is audited (auth.oauth_failed) |
| Secrets | Client secrets live only in Worker secrets on ahena-api and are sent only to the providers' token endpoints. Codes, tokens and verifiers are never logged, audited or returned (tests check this) |
Callback URLs
The callbacks live on the dashboard origin, because that's where the session cookie is set:
https://app.ahena.io/auth/oauth/github/callbackhttps://app.ahena.io/auth/oauth/google/callback
The dashboard relays them to the API (POST /v1/auth/oauth/<provider>/callback, through the
Worker service binding). The API validates the state, exchanges the code and returns a
session, which the dashboard sets as its cookie. Setting app.ahena.io's cookie from an
api.ahena.io callback would need a cookie shared across subdomains or a token in a URL, so
Ahena does neither.
Email verification (password accounts)
GitHub and Google accounts are verified by the provider. A password account gets a link at sign-up (and again from the dashboard banner, at most once a minute and five times a day):
- The link (
/verify-email?token=ahena_verify_…) works once, for 24 hours, and only while the account still uses the address it was sent to. Only the token's SHA-256 hash is stored. - The page asks for a button press instead of verifying on load, because mail scanners open links.
- A verified email makes the account unclaimable by a GitHub/Google sign-in with that address (the verified-email-wins rule above only applies to never-verified accounts), and it never links a provider to the account without the account's password (see Account linking).
- Paid plans need a verified email (
forbidden,details.reason: "email_unverified"). Everything else works before verifying.
Forgotten password
"Forgot your password?" on the sign-in page (/forgot-password) asks for an email address.
POST /v1/auth/password-reset {email}always answers202 {"accepted": true}, whether or not an account uses the address, so it never reveals who has an account. Rate limited per client like the other sign-in routes; per account at most one email a minute and five a day (over that, nothing is sent and the answer is the same).- When an account uses the address (password or GitHub/Google-only), it gets a single-use link,
/reset-password?token=ahena_reset_…, that works for 1 hour. Only the token's SHA-256 hash is stored; asking again makes earlier links stop working, and the link stops working if the account's email changes. The page sends no Referer and removes the token from the address bar once read. POST /v1/auth/password-reset/confirm {token, password}checks the password policy (before using up the link), then sets the password, marks the email verified (the link proves who reads the mailbox) and signs the account out everywhere: web, CLI, agent and CI tokens, CLI sign-ins approved but not yet collected, and sign-ins waiting for a two-factor code. It doesn't sign anyone in; the next step is the sign-in page.- Two-factor stays on. Resetting the password never turns it off: the next sign-in still asks for the code (or a recovery code).
- Never-verified accounts: someone may have registered your address before you. Using the
link makes the account yours on the same terms as a GitHub/Google sign-in that proves the
address (see Account linking): organization memberships, other sign-in methods and two-factor
set up by the registrant are removed (
member.removedwithreason: "unverified_account_reset"). - Audited:
auth.password_reset_requested,auth.password_reset(what was revoked or removed,emailWasVerified,mfaKept), anduser.email_verifiedwhen it was the first verification. Tokens never appear in audit metadata or request logs.
Team invitations
Owners and admins invite people by email from the Team page (POST /v1/orgs/:org/invitations {email, role}; only owners invite owners; AI agent sessions can't, since managing people is
never allowed to them).
- The invitee gets a link,
/invite/ahena_invite_…, that works once, for 7 days. Only its hash is stored. Inviting the same address again replaces the earlier invitation (its link stops working); revoking (DELETE /v1/orgs/:org/invitations/:id) does the same. Pending invitations are listed for members (GET /v1/orgs/:org/invitations). - Accepting needs a signed-in account whose verified email is the invited address (any letter case). Signed out, the link offers sign-in or sign-up and comes back; a new account verifies its email first. The organization's member limit is checked when inviting and again, under the same lock as other member changes, when accepting.
GET /v1/invitations/:token(signed in) previews the link: organization name, role, who invited, status (pending, accepted, revoked, expired) and whether it's for the signed-in account. Someone else holding the link learns nothing about the organization beyond its name.POST /v1/invitations/:token/acceptadds the membership (auditedmember.addedwithvia: "invitation"). The emails escape names and organization names; request logs redact the token in the path. Inviting is audited asmember.invitedand revoking asmember.invitation_revoked. At most 50 invitations per organization per day.- Adding someone who already has a verified Ahena account directly (
POST /v1/orgs/:org/members) still works.
Two-factor sign-in (authenticator app)
Optional, per account (Account → Two-factor sign-in). Once on, every web sign-in asks for a code: password, GitHub and Google alike. CLI and MCP sign-in go through dashboard approval, so they inherit it; CI tokens are unaffected.
- Codes: TOTP (RFC 6238: SHA-1, 30-second steps, 6 digits), one step of clock drift either way. A step that was already accepted is refused, so an observed code can't be replayed.
- Sign-in: the first factor returns
{ mfaRequired, challenge }instead of a session (/v1/auth/login, orstatus: "mfa_required"from the OAuth callback).POST /v1/auth/mfawith the challenge and a code creates the session. A challenge lasts 5 minutes and allows 5 codes. A password sign-in that was connecting GitHub/Google finishes the link at this step. - Recovery codes: 10 single-use codes, shown once at setup (hashes stored); they can be replaced.
- Storage: the TOTP secret is envelope-encrypted like provider credentials, bound to its user,
and included in key rotation (
rewrap-keys). - Changes (set up, new recovery codes, turn off) need a sign-in within the last 15 minutes;
replacing codes and turning it off also need a current code. Everything is audited
(
auth.mfa_enabled,auth.mfa_disabled,auth.mfa_failed,auth.loginwithmfa), never the secret or codes.
Sessions and what each may do
| Session | Made by | Lifetime | Notes |
|---|---|---|---|
web (ahena_sess_…) |
signing in to the dashboard | 14 days | The only session that can approve changes that matter, approve a CLI sign-in, or change sign-in methods and two-factor (recent sign-in required for those). |
CLI (ahena_cli_…) |
ahena login (device code approved in the browser) |
90 days | Plans and applies; changes that matter wait for approval in the dashboard. |
agent (ahena_agent_…) |
ahena mcp, from the CLI session |
12 hours | Marked via: mcp by the server. Can plan and ask for approval; never decides approvals, reveals secrets, manages people, billing or the organization, or creates tokens. |
CI (ahena_ci_…) |
ahena ci token create |
up to 365 days | Read-only, one project, deploy-check routes only. |
Approving a CLI sign-in (the device code page) needs a web session, so neither a CLI token nor an agent session can hand out another CLI token. See approvals.md.