Authentication Flows
RESTHeart CloudThis page documents all authentication and membership flows in restheart-accounts. Each flow lists the API endpoints, backend logic, SPI interactions, and the expected frontend behavior.
1. Registration (new user)
POST /auth/register โ public.
Request body:
{
"firstName": "Alice",
"lastName": "Rossi",
"teamName": "Acme",
"email": "alice@acme.com",
"password": "correct-horse-battery",
}
sequenceDiagram
participant C as Client
participant R as RESTHeart
participant M as MongoDB
participant E as Email
C->>R: POST /auth/register
Note over R: validate fields, zxcvbn score โฅ 3
R->>M: check email uniqueness
R->>M: createInitialTeam(userId, teamName)
R->>M: create user (roles: $unauthenticated + verify token)
M-->>R: OK
R->>E: verification email
R-->>C: 201 Created
Flow:
-
Validate required fields and password strength (zxcvbn score >= 3).
-
Check email uniqueness โ
409 Conflictif already registered. -
MembershipProvider.createInitialTeam(userId, teamName)โ creates team. -
Create user with
roles: ["$unauthenticated"]and verification token. -
Send verification email.
-
Return
201 Created.
Consents: User consent tracking (e.g. terms & conditions, privacy policy) is not handled by the Accounts plugin. See Consents Management for guidance on implementing consent persistence via a response interceptor.
Frontend route: /auth/signup
2. Email Verification
GET /auth/verify?email=…&token=… โ public.
sequenceDiagram
participant C as Client
participant R as RESTHeart
participant M as MongoDB
Note over C: clicks link in email
C->>R: GET /auth/verify?email=โฆ&token=โฆ
R->>M: find user, compare token (constant-time)
Note over R,M: TTL check (default 7 days)
R->>M: set roles: user, unset verify token
M-->>R: OK
R-->>C: 302 redirect + JWT cookie โ frontend-app-url
Flow:
-
User clicks link in email.
-
Frontend route
/auth/verifyredirects browser to backend endpoint. -
Backend verifies token, sets
roles: ["user"], issues JWT cookie. -
302redirect tofrontend-app-url. -
Frontend detects authentication, shows success screen.
Frontend route: /auth/verify
3. Sign In
POST /token/cookie (Basic Auth) + GET /users/me
sequenceDiagram
participant C as Client
participant R as RESTHeart
participant M as MongoDB
C->>R: POST /token/cookie (Basic Auth)
R->>M: verify credentials
M-->>R: OK
R-->>C: JWT cookie
C->>R: GET /users/me
R->>M: load user profile
M-->>R: user document
R-->>C: user document
Flow:
-
Frontend sends Basic Auth credentials.
-
Backend sets JWT cookie.
-
Frontend calls
GET /users/meto load user profile.
Social login (Google/GitHub) is also available via OAuth buttons.
Frontend route: /auth/login
|
Note
|
POST /token/cookie is one of three ways RESTHeart can hand a token to a client. It’s the right choice when the frontend and RESTHeart share a registrable domain, as shown above โ for a cross-origin SPA, POST /token (token in the response body) is the usual alternative, since /token/cookie’s `HttpOnly cookie doesn’t travel reliably cross-origin (Safari blocks third-party cookies outright regardless of SameSite). See Choosing How to Deliver the Token for the full comparison, decision guide, and a sequence diagram for each of the three flows (cookie, token-in-body, and GET /token/redirect, 9.5+).
|
4. Password Reset
4a. Request reset link
POST /auth/forgot-password โ public. Always returns 202 (no email enumeration).
Request body:
{ "email": "alice@acme.com" }
Frontend route: /auth/forgot-pwd
4b. Apply new password
PATCH /auth/reset-password โ public.
Request body:
{
"email": "alice@acme.com",
"token": "<token from email>",
"password": "new-secure-password"
}
Auto-login on success (JWT cookie set by the plugin).
Frontend route: /auth/reset-password?email=…&token=…
sequenceDiagram
participant C as Client
participant R as RESTHeart
participant M as MongoDB
participant E as Email
Note over C,E: Step 1 โ Request reset link
C->>R: POST /auth/forgot-password {email}
R->>M: lookup user (result not revealed)
alt user found and verified
R->>M: generate + store reset token (TTL 1h)
R->>E: password reset email
end
R-->>C: 202 Accepted (always)
Note over C,M: Step 2 โ Apply new password
C->>R: PATCH /auth/reset-password {email, token, password}
Note over R: constant-time token compare + TTL check
Note over R: zxcvbn password strength check
R->>M: hash + save password, unset reset token
M-->>R: OK
R-->>C: 200 OK
5. Invite New User (no existing account)
POST /auth/invite โ requires owner role.
Request body:
{ "email": "bob@example.com", "role": "member" }
sequenceDiagram
participant O as Owner
participant R as RESTHeart
participant M as MongoDB
participant E as Email
O->>R: POST /auth/invite {email, role}
R->>M: check if user exists
M-->>R: not found
R->>M: check auth_invitations for pending invite (same email+org)
M-->>R: none found
R->>M: create user (roles: $unauthenticated, no password)
R->>M: create auth_invitations doc (isNewUser: true, TTL 7d)
R->>E: activation email โ /auth/activate?email=โฆ&token=โฆ
R-->>O: 201 Created
Flow:
-
Check if user exists.
-
Check
auth_invitationsfor a pending invite for the same email+org โ returns409if one exists. -
User does not exist: create user with
roles: ["$unauthenticated"](noinviteTokenon user document). -
Create invitation document in
auth_invitationswithisNewUser: true. -
Send invite email with link to
{frontend-url}/auth/activate?email=…&token=….
|
Note
|
Membership is not added at invite time. MembershipProvider.addMember() is called only when the user accepts the invitation (via password activation or OAuth), keeping the user unassociated until they explicitly accept.
|
5a. Read invitation metadata
GET /auth/invitation?email=…&token=… โ public.
The frontend calls this endpoint on page load to determine which UI to render (set-password form vs. log-in form) and to display the org name and role.
Both email and token parameters are required. The pair is known only to the invitee (delivered via private email link), so this endpoint cannot be used for enumeration.
Response 200 OK:
{
"email": "bob@example.com",
"teamName": "Acme Corp",
"role": "member",
"isNewUser": true,
"expiresAt": "2026-06-24T10:00:00Z"
}
Error responses: 400 (missing params) ยท 404 (invalid or expired token)
5b. Accept invitation โ set password
PATCH /auth/activate โ public.
Request body:
{
"email": "bob@example.com",
"token": "<token from email>",
"password": "correct-horse-battery"
}
sequenceDiagram
participant C as Client
participant R as RESTHeart
participant M as MongoDB
Note over C: clicks link in email โ /auth/activate
C->>R: PATCH /auth/activate {email, token, password}
R->>M: find invitation by (email, token) in auth_invitations
Note over R: verify isNewUser=true, TTL check
Note over R: zxcvbn password strength check
R->>M: set password, roles โ user
R->>M: MembershipProvider.addMember(userId, teamId, role)
R->>M: delete invitation from auth_invitations
M-->>R: OK
R-->>C: 200 OK + JWT cookie (auto-login)
Flow:
-
Find invitation by
(email, token)inauth_invitations(not on user document). -
Verify
isNewUser: trueโ returns400otherwise (wrong endpoint). -
Check expiry.
-
Set password, assign
roles: ["user"]. -
MembershipProvider.addMember(userId, teamId, role)โ adds user to the team and sets active team. -
Delete invitation from
auth_invitations(one-shot token). -
Issue JWT with the newly set active team.
-
Return
200 OK+ JWT cookie (auto-login). Consents are not managed by this endpoint. See Consents Management for details.
Frontend route: /auth/activate?email=…&token=…
5c. Accept invitation โ via OAuth
The activation page (/auth/activate) shows OAuth buttons (Google/GitHub) with the pendingInviteToken passed as a query parameter to the OAuth authorize URL.
OAuth URL: {api-base-url}/auth/oauth/authorize/{provider}?pendingInviteToken=…
sequenceDiagram
participant C as Client
participant R as RESTHeart
participant P as OAuth Provider
participant M as MongoDB
Note over C: on /auth/activate page โ clicks Sign in with Google
C->>R: GET /auth/oauth/authorize/google?pendingInviteToken=โฆ
R-->>C: 302 โ provider consent screen
C->>P: consent
P-->>C: 302 โ /auth/oauth/callback?code=โฆ&state=โฆ
C->>R: GET /auth/oauth/callback/google
Note over R: validate state, exchange code for profile
Note over R: user is $unauthenticated + pendingInviteToken present
R->>M: find invitation by (email, pendingInviteToken) in auth_invitations
R->>M: MembershipProvider.addMember(userId, teamId, role)
Note over R: activateViaOAuth() โ assigns roles:[user], stores consents
R->>M: delete auth_invitations doc
R-->>C: 302 + JWT cookie + #access_token= fragment โ frontend-app-url
Flow:
-
User clicks "Sign in with Google/GitHub".
-
OAuth callback detects
$unauthenticateduser withpendingInviteToken. -
Find invitation by
(email, pendingInviteToken)inauth_invitations, callMembershipProvider.addMember(userId, teamId, role)to set team. -
MembershipProvider.activateViaOAuth(userId, consents)โ assignsroles: ["user"], stores consents. -
Delete invitation from
auth_invitations. Issue JWT, set the auth cookie, and redirect tofrontend-app-urlwith the token also appended as a URL fragment (#access_token=…, since 9.5 โ see Choosing How to Deliver the Token).
6. Invite Existing User (has account)
POST /auth/invite โ same endpoint as above, different behavior for existing users.
sequenceDiagram
participant O as Owner
participant R as RESTHeart
participant M as MongoDB
participant E as Email
O->>R: POST /auth/invite {email, role}
R->>M: check if user exists
M-->>R: found
R->>M: isMember(userId, teamId)?
M-->>R: not a member
R->>M: create auth_invitations doc (isNewUser: false, TTL 7 days)
R->>E: invite email โ /invitations/accept?email=โฆ&token=โฆ
R-->>O: 201 Created
Flow:
-
User already exists โ do NOT add membership immediately.
-
Create an invitation document in
auth_invitationswithisNewUser: false. -
Send invite email with link to
{frontend-url}/invitations/accept?email=…&token=….
|
Note
|
All invitation tokens โ for both new and existing users โ are stored exclusively in the The
|
6a. Accept invitation โ log in then accept
POST /auth/accept-invite โ requires authentication.
Request body:
{ "token": "<token from email>" }
sequenceDiagram
participant C as Client
participant R as RESTHeart
participant M as MongoDB
Note over C: clicks link โ /invitations/accept?email=โฆ&token=โฆ
Note over C: not logged in โ redirected to /auth/login
C->>R: POST /token/cookie (login)
R-->>C: JWT cookie
C->>R: POST /auth/accept-invite {token}
R->>M: find invitation by token in auth_invitations
Note over R: verify isNewUser=false, not expired, email matches caller
R->>M: MembershipProvider.addMember(userId, teamId, role)
R->>M: MembershipProvider.setActiveMembership(userId, teamId)
R->>M: delete invitation document
M-->>R: OK
R-->>C: 200 OK
Flow:
-
Find invitation by token in
auth_invitations(must not be expired). -
Verify
isNewUser: falseโ returns400otherwise (wrong endpoint). -
Verify the invitation email matches the authenticated user.
-
MembershipProvider.addMember(userId, teamId, role)โ adds user to team. -
MembershipProvider.setActiveMembership(userId, teamId)โ switches the user’s active team to the newly joined one. -
Delete the invitation document from
auth_invitations. -
Return
200 OK.
|
Note
|
The active team is switched to the accepted team so that the next login (or explicit team switch) places the user in the new context. |
Frontend route: /invitations/accept?email=…&token=…
If user is not logged in, AuthGuard redirects to /auth/login?returnUrl=/invitations/accept?email=…&token=….
6b. Accept invitation โ via OAuth
Existing users can also accept an invitation by logging in via OAuth with the pendingInviteToken.
sequenceDiagram
participant C as Client
participant R as RESTHeart
participant P as OAuth Provider
participant M as MongoDB
Note over C: on /invitations/accept page โ clicks Sign in with Google
C->>R: GET /auth/oauth/authorize/google?pendingInviteToken=โฆ
R-->>C: 302 โ provider consent screen
C->>P: consent
P-->>C: 302 โ /auth/oauth/callback?code=โฆ&state=โฆ
C->>R: GET /auth/oauth/callback/google
Note over R: user is NOT $unauthenticated + pendingInviteToken present
R->>M: find invitation by (email, token) in auth_invitations
R->>M: MembershipProvider.addMember(userId, teamId, role)
R->>M: delete invitation document
R-->>C: 302 + JWT cookie + #access_token= fragment โ frontend-app-url
Flow:
-
User clicks "Sign in with Google/GitHub" on the
/invitations/acceptpage. -
OAuth callback detects the user is NOT
$unauthenticatedand apendingInviteTokenis present. -
Find invitation by
(email, pendingInviteToken)inauth_invitations. -
MembershipProvider.addMember(userId, teamId, role). -
Delete invitation. Issue JWT, set the auth cookie, and redirect to
frontend-app-urlwith the token also appended as a URL fragment (#access_token=…, since 9.5).
7. OAuth Login
GET /auth/oauth/authorize/{provider} โ GET /auth/oauth/callback/{provider}
sequenceDiagram
participant B as Browser
participant R as RESTHeart
participant P as OAuth Provider
participant M as MongoDB
B->>R: GET /auth/oauth/authorize/google
Note over R: generate CSRF state, store in oauth_codes (TTL 10 min)
R-->>B: 302 โ provider consent screen
B->>P: consent
P-->>B: 302 โ /auth/oauth/callback?code=โฆ&state=โฆ
B->>R: GET /auth/oauth/callback/google
Note over R: validate state (constant-time, findOneAndDelete)
R->>P: exchange code for access token
P-->>R: access token
R->>P: fetch user profile
P-->>R: profile
R->>M: upsert user document
Note over R: issue JWT
R-->>B: 302 + JWT cookie + #access_token= fragment โ frontend-url
Flow:
-
Frontend builds authorize URL with optional
pendingInviteToken,returnUrl,consentsAccepted. -
Backend stores CSRF state token, redirects to provider.
-
Provider redirects back with authorization code.
-
Backend exchanges code for user profile, finds or creates user.
-
If user is
$unauthenticated(new invited user): activate viaMembershipProvider.activateViaOAuth(). -
If user is NOT
$unauthenticatedandpendingInviteTokenis present (existing invited user): accept invitation viaauth_invitations, calladdMember(). -
Issue JWT, set the auth cookie, and redirect to
frontend-app-urlwith the token also appended as a URL fragment.
|
Note
|
Since RESTHeart 9.5, OAuthCallback hands the token back two ways on the same redirect: the rh_auth HttpOnly cookie (unchanged, for same-site frontends) and a #access_token=… URL fragment (for frontends using in-memory Bearer-token session handling, which can’t rely on a cross-origin cookie โ see Choosing How to Deliver the Token). The frontend reads location.hash on landing and clears it via history.replaceState.
|
8. Switch Team
POST /auth/switch-team โ requires authentication.
Request body:
{ "teamId": "<team-id>" }
sequenceDiagram
participant C as Client
participant R as RESTHeart
participant M as MongoDB
C->>R: POST /auth/switch-team {teamId}
R->>M: verify membership in teamId
R->>M: MembershipProvider.setActiveMembership(userId, teamId)
M-->>R: OK
Note over R: reissue JWT with new team claim
R-->>C: new JWT cookie
Calls MembershipProvider.setActiveMembership(userId, teamId) and reissues JWT.
9. List Teams
GET /auth/teams โ requires authentication.
sequenceDiagram
participant C as Client
participant R as RESTHeart
participant M as MongoDB
C->>R: GET /auth/teams
R->>M: MembershipProvider.listMemberships(userId)
M-->>R: memberships
R-->>C: team list
Returns all teams the user belongs to via MembershipProvider.listMemberships(userId).
10. Remove a Member
DELETE /auth/remove-member โ requires owner role.
Request body:
{ "email": "bob@example.com" }
sequenceDiagram
participant O as Owner
participant R as RESTHeart
participant M as MongoDB
O->>R: DELETE /auth/remove-member {email}
R->>M: verify caller is owner of active team
R->>M: verify target is a member (404 otherwise)
Note over R: reject if owner tries to remove themselves (400)
R->>M: MembershipProvider.removeMember(userId, teamId)
M-->>R: OK
R-->>O: 200 OK
Flow:
-
Verify caller is
ownerof their active team. -
Check target is a member of that team โ
404otherwise. -
Prevent owner from removing themselves โ
400. -
MembershipProvider.removeMember(userId, teamId)โ removes membership on both user and team side; clears active team if it was the removed one. -
Return
200 OK.
MembershipProvider SPI: removeMember(userId, teamId)
11. Update a Member’s Role
PATCH /auth/member-role โ requires owner role.
Request body:
{ "email": "bob@example.com", "role": "member" }
sequenceDiagram
participant O as Owner
participant R as RESTHeart
participant M as MongoDB
O->>R: PATCH /auth/member-role {email, role}
R->>M: verify caller is owner of active team
Note over R: validate role (must be memberRoleName or ownershipRole)
R->>M: verify target is a member (404 otherwise)
R->>M: MembershipProvider.updateMemberRole(userId, teamId, newRole)
M-->>R: OK
R-->>O: 200 OK
role must be the configured member-role-name (default "member") or ownershipRole.
Ownership transfer is not supported via this endpoint.
Flow:
-
Verify caller is
ownerof their active team. -
Validate role value โ
400if notmemberRoleNameorownershipRole. -
Check target is a member of that team โ
404otherwise. -
MembershipProvider.updateMemberRole(userId, teamId, newRole)โ updates role on both user and team side. -
Return
200 OK.
MembershipProvider SPI: updateMemberRole(userId, teamId, newRole)
MembershipProvider SPI Reference
| Method | Called by | Cloud implementation |
|---|---|---|
|
|
Creates org in |
|
|
Checks |
|
|
Adds teamId to user’s |
|
JWT issuance, |
Reads |
|
|
Iterates |
|
|
Sets |
|
|
|
|
|
Positional |
|
OAuth callback for invited users |
Assigns |