Documentation menu

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_at and last_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/callback
  • https://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 answers 202 {"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.removed with reason: "unverified_account_reset").
  • Audited: auth.password_reset_requested, auth.password_reset (what was revoked or removed, emailWasVerified, mfaKept), and user.email_verified when 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/accept adds the membership (audited member.added with via: "invitation"). The emails escape names and organization names; request logs redact the token in the path. Inviting is audited as member.invited and revoking as member.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, or status: "mfa_required" from the OAuth callback). POST /v1/auth/mfa with 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.login with mfa), 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.