Edit Page

Authentication Flows

RESTHeart Cloud

This 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:

  1. Validate required fields and password strength (zxcvbn score >= 3).

  2. Check email uniqueness โ€” 409 Conflict if already registered.

  3. MembershipProvider.createInitialTeam(userId, teamName) โ€” creates team.

  4. Create user with roles: ["$unauthenticated"] and verification token.

  5. Send verification email.

  6. 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:

  1. User clicks link in email.

  2. Frontend route /auth/verify redirects browser to backend endpoint.

  3. Backend verifies token, sets roles: ["user"], issues JWT cookie.

  4. 302 redirect to frontend-app-url.

  5. 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:

  1. Frontend sends Basic Auth credentials.

  2. Backend sets JWT cookie.

  3. Frontend calls GET /users/me to 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

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:

  1. Check if user exists.

  2. Check auth_invitations for a pending invite for the same email+org โ€” returns 409 if one exists.

  3. User does not exist: create user with roles: ["$unauthenticated"] (no inviteToken on user document).

  4. Create invitation document in auth_invitations with isNewUser: true.

  5. 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:

  1. Find invitation by (email, token) in auth_invitations (not on user document).

  2. Verify isNewUser: true โ€” returns 400 otherwise (wrong endpoint).

  3. Check expiry.

  4. Set password, assign roles: ["user"].

  5. MembershipProvider.addMember(userId, teamId, role) โ€” adds user to the team and sets active team.

  6. Delete invitation from auth_invitations (one-shot token).

  7. Issue JWT with the newly set active team.

  8. 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:

  1. User clicks "Sign in with Google/GitHub".

  2. OAuth callback detects $unauthenticated user with pendingInviteToken.

  3. Find invitation by (email, pendingInviteToken) in auth_invitations, call MembershipProvider.addMember(userId, teamId, role) to set team.

  4. MembershipProvider.activateViaOAuth(userId, consents) โ€” assigns roles: ["user"], stores consents.

  5. Delete invitation from auth_invitations. Issue JWT, set the auth cookie, and redirect to frontend-app-url with 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:

  1. User already exists โ€” do NOT add membership immediately.

  2. Create an invitation document in auth_invitations with isNewUser: false.

  3. 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 auth_invitations collection (same database as users and acl). No invite fields (inviteToken, inviteCreatedAt) are written to user documents. This allows multiple pending invitations per user across different teams.

The auth_invitations document schema:

{
  "_id":       {"$oid": "..."},
  "email":     "user@example.com",
  "token":     "64-char hex",
  "teamId":     {"$oid": "..."},
  "role":      "member",
  "isNewUser": false,
  "createdAt": {"$date": 1234567890},
  "expiresAt": {"$date": 1234567890}
}

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:

  1. Find invitation by token in auth_invitations (must not be expired).

  2. Verify isNewUser: false โ€” returns 400 otherwise (wrong endpoint).

  3. Verify the invitation email matches the authenticated user.

  4. MembershipProvider.addMember(userId, teamId, role) โ€” adds user to team.

  5. MembershipProvider.setActiveMembership(userId, teamId) โ€” switches the user’s active team to the newly joined one.

  6. Delete the invitation document from auth_invitations.

  7. 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:

  1. User clicks "Sign in with Google/GitHub" on the /invitations/accept page.

  2. OAuth callback detects the user is NOT $unauthenticated and a pendingInviteToken is present.

  3. Find invitation by (email, pendingInviteToken) in auth_invitations.

  4. MembershipProvider.addMember(userId, teamId, role).

  5. Delete invitation. Issue JWT, set the auth cookie, and redirect to frontend-app-url with 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:

  1. Frontend builds authorize URL with optional pendingInviteToken, returnUrl, consentsAccepted.

  2. Backend stores CSRF state token, redirects to provider.

  3. Provider redirects back with authorization code.

  4. Backend exchanges code for user profile, finds or creates user.

  5. If user is $unauthenticated (new invited user): activate via MembershipProvider.activateViaOAuth().

  6. If user is NOT $unauthenticated and pendingInviteToken is present (existing invited user): accept invitation via auth_invitations, call addMember().

  7. Issue JWT, set the auth cookie, and redirect to frontend-app-url with 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:

  1. Verify caller is owner of their active team.

  2. Check target is a member of that team โ€” 404 otherwise.

  3. Prevent owner from removing themselves โ€” 400.

  4. MembershipProvider.removeMember(userId, teamId) โ€” removes membership on both user and team side; clears active team if it was the removed one.

  5. 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:

  1. Verify caller is owner of their active team.

  2. Validate role value โ€” 400 if not memberRoleName or ownershipRole.

  3. Check target is a member of that team โ€” 404 otherwise.

  4. MembershipProvider.updateMemberRole(userId, teamId, newRole) โ€” updates role on both user and team side.

  5. Return 200 OK.

MembershipProvider SPI: updateMemberRole(userId, teamId, newRole)

MembershipProvider SPI Reference

Method Called by Cloud implementation

createInitialTeam(userId, teamName)

/auth/register, OAuth new user

Creates org in orgs collection, adds user as owner

isMember(userId, teamId)

/auth/invite duplicate check

Checks orgs array on user document

addMember(userId, teamId, role)

/auth/invite (new user, immediately), /auth/accept-invite, OAuth existing user

Adds teamId to user’s orgs array, sets role

activeMembership(userId)

JWT issuance, /auth/teams

Reads org field, loads org name and role

listMemberships(userId)

GET /auth/teams

Iterates orgs array, loads each org

setActiveMembership(userId, teamId)

POST /auth/switch-team

Sets org field on user document

removeMember(userId, teamId)

DELETE /auth/remove-member

$pull from user’s orgs array and org’s members array; unsets org if it was active

updateMemberRole(userId, teamId, newRole)

PATCH /auth/member-role

Positional $set on role in user’s orgs array and org’s members array

activateViaOAuth(userId, consents)

OAuth callback for invited users

Assigns roles: ["user"], stores consents