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=…​&delivery=cookie|fragment โ€” 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
  alt delivery=cookie (default)
    R-->>C: 302 redirect + JWT cookie โ†’ frontend-app-url
  else delivery=fragment
    R-->>C: 302 redirect โ†’ frontend-app-url#access_token=โ€ฆ
  end

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 a JWT.

  4. 302 redirect to frontend-app-url; the JWT is delivered according to the delivery query parameter:

    • delivery=cookie (or omitted, default) โ€” sets the JWT cookie. For same-origin setups.

    • delivery=fragment โ€” appends the JWT as a URL fragment (#access_token=…​&token_type=Bearer&expires_in=…​), same mechanism as GET /token/redirect and the OAuth callback. For cross-origin SPAs using Bearer token auth, where browsers block third-party cookies. No cookie is set.

  5. Frontend detects authentication (cookie, or token read from window.location.hash), 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).

If the account has not completed email verification yet (still $unauthenticated), there is no password to reset: instead, a new emailVerificationToken is generated and the activation email is re-sent โ€” the account owner’s only self-service way to recover a lost or expired verification link (registration itself rejects the email as already taken). See Password Reset for details.

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. The delivery query parameter selects how the fresh JWT is handed back:

  • delivery=cookie (or omitted, default) โ€” sets the rh_auth HttpOnly JWT cookie. For same-origin setups.

  • delivery=body (since 9.6.0) โ€” returns the token in the JSON response body (access_token, token_type, expires_in) and in the Auth-Token header, and sets no cookie. For cross-origin SPAs using Bearer-token auth, so no second POST /token (re-sending the password) is needed.

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
  else user found but not yet verified
    R->>M: generate + store new emailVerificationToken
    R->>E: verification email (re-sent)
  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 (JWT via cookie or body, per delivery)

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 via cookie or body, per delivery)

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; the fresh JWT is delivered per the delivery query parameter (auto-login). Consents are not managed by this endpoint. See Consents Management for details.

Auto-login delivery โ€” same delivery selector as /auth/reset-password:

  • delivery=cookie (or omitted, default) โ€” sets the rh_auth HttpOnly JWT cookie. For same-origin setups.

  • delivery=body (since 9.6.0) โ€” returns the token in the JSON response body (access_token, token_type, expires_in) and in the Auth-Token header, and sets no cookie. For cross-origin SPAs using Bearer-token auth.

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 team = {_id, role}
  R-->>C: 200 OK (JWT via cookie or body, per delivery)

Calls MembershipProvider.setActiveMembership(userId, teamId) and reissues the JWT. The reissued token is delivered per the delivery query parameter โ€” cookie (default, sets the rh_auth cookie) or body (since 9.6.0; returns access_token/token_type/expires_in in the response body plus the Auth-Token header, no cookie). The response body also carries the new active team and role.

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

Response example
[
  { "id": { "$oid": "64a1b2c3..." }, "name": "Acme Corp", "description": "Main workspace", "role": "owner",  "active": true },
  { "id": { "$oid": "74b2c3d4..." }, "name": "Side Gig",  "role": "member", "active": false }
]

The description field is included when the team document has a non-null description. Omitted otherwise.

10. Create an Additional Team

POST /auth/teams โ€” requires authentication. Same URI as 9. List Teams, distinguished by HTTP method (GET vs POST).

Lets an already-registered user spin up an additional workspace โ€” e.g. "New Workspace" the way Slack/Notion let you โ€” the same way createInitialTeam does during signup, except this can be called any number of times.

Request body:

{ "teamName": "New Workspace" }
sequenceDiagram
  participant C as Client
  participant R as RESTHeart
  participant M as MongoDB

  C->>R: POST /auth/teams {teamName}
  R->>M: MembershipProvider.createTeam(userId, teamName)
  Note over R,M: adds caller as owner, sets team as newly active
  M-->>R: OK
  Note over R: reissue JWT with team = {_id, role}
  R-->>C: 201 Created (JWT via cookie or body, per delivery)

Flow:

  1. Validate teamName โ€” 400 if missing or blank.

  2. MembershipProvider.createTeam(userId, teamName) โ€” creates the team, adds the caller as owner, and sets it as the newly active membership (unlike createInitialTeam, which only activates a team if the user has none yet).

  3. Reissue the JWT with the new team claim, delivered per the delivery query parameter (same cookie / body selector as 8. Switch Team).

  4. Return 201 Created with { "id": …​, "name": …​, "role": "owner" }.

MembershipProvider SPI: createTeam(userId, teamName)

11. List Team Members

GET /auth/team/members โ€” requires authentication.

Returns the caller’s active team’s member list (name, email, role, joinedAt), denormalized against each member’s profile. There is no caller-supplied team filter โ€” always the caller’s own active team, avoiding the need for separate authorization logic for "can I see team X’s roster."

sequenceDiagram
  participant C as Client
  participant R as RESTHeart
  participant M as MongoDB

  C->>R: GET /auth/team/members
  R->>M: MembershipProvider.listTeamMembers(teamId)
  Note over R,M: reads team.members[], batch-fetches users for display names
  M-->>R: members
  R-->>C: member list

Response 200 OK:

[
  { "email": "alice@acme.com", "name": "Alice Rossi", "role": "owner",  "joinedAt": "2026-01-01T00:00:00Z" },
  { "email": "bob@acme.com",   "name": "Bob Smith",   "role": "member", "joinedAt": "2026-01-02T00:00:00Z" }
]

MembershipProvider SPI: listTeamMembers(teamId)

12. 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)

13. 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)

14. Update a Team

PATCH /auth/team โ€” requires owner role. Same URI as 15. Delete a Team, distinguished by HTTP method (PATCH vs DELETE).

Request body (partial updates allowed):

{ "name": "Acme Corp", "description": "Our team workspace" }
sequenceDiagram
  participant O as Owner
  participant R as RESTHeart
  participant M as MongoDB

  O->>R: PATCH /auth/team {name?, description?}
  R->>M: verify caller is owner of active team
  R->>M: MembershipProvider.updateTeam(teamId, name, description)
  M-->>R: OK
  R-->>O: 200 OK

Flow:

  1. Verify caller is owner of their active team โ€” 403 otherwise.

  2. Validate body โ€” 400 if neither name nor description is present, or if name is blank.

  3. MembershipProvider.updateTeam(teamId, name, description) โ€” partial $set; a null parameter leaves that field unchanged.

  4. Return 200 OK.

MembershipProvider SPI: updateTeam(teamId, name, description)

15. Delete a Team

DELETE /auth/team โ€” requires owner role.

Deletes the caller’s active team, but only if it has no other members. The "no other members" invariant is enforced atomically, server-side: a client-side pre-check (list members, see only the caller, then delete) would be a race condition against a concurrent invite acceptance.

sequenceDiagram
  participant O as Owner
  participant R as RESTHeart
  participant M as MongoDB

  O->>R: DELETE /auth/team
  R->>M: verify caller is owner of active team
  R->>M: MembershipProvider.deleteTeam(userId, teamId)
  Note over R,M: atomic findOneAndDelete guarded by members.size <= 1
  alt team was empty
    M-->>R: deleted; caller's membership/active-team pointer cleared
    R-->>O: 200 OK
  else team still has other members
    M-->>R: not deleted
    R-->>O: 409 Conflict
  end

Flow:

  1. Verify caller is owner of their active team โ€” 403 otherwise.

  2. MembershipProvider.deleteTeam(userId, teamId) โ€” atomically deletes the team only if it has at most one member; returns false (does not throw) otherwise.

  3. If deletion failed because the team still has other members, return 409 Conflict. If it failed because the team no longer exists (already deleted by a concurrent/duplicate request), return 404 Not Found.

  4. On success, the caller’s own membership entry and active-team pointer are cleared โ€” they are left with no active team and must create or be invited into a new one.

  5. Return 200 OK.

MembershipProvider SPI: deleteTeam(userId, teamId)

16. Update Profile

PATCH /auth/profile โ€” requires authentication.

Self-service update of the caller’s own profile.name / profile.surname fields, exposed to clients as firstName / lastName (matching /auth/register’s request body). This dedicated endpoint self-registers its own ACL allow rule at startup โ€” same pattern as every other `restheart-accounts endpoint โ€” instead of relying on generic PATCH /users/{email} plus a tenant-configured ACL allow rule, which isn’t guaranteed to exist (see the /users Access section on the overview page).

Request body (partial updates allowed):

{ "firstName": "Alice", "lastName": "Rossi" }

Flow:

  1. Validate body โ€” 400 if neither firstName nor lastName is present, or if either is blank.

  2. $set profile.name / profile.surname on the caller’s own user document (writes directly via the MongoDB driver, not through the generic REST resource).

  3. Return 200 OK.

Frontend route: /account (settings page)

17. Change Password

PATCH /auth/change-password โ€” requires authentication.

Lets a logged-in user change their password in-session, given their current password โ€” unlike PATCH /auth/reset-password, which is the public, unauthenticated "forgot password" flow (emailed one-shot token). Before this endpoint, a logged-in user could only change their password by calling /auth/forgot-password on themselves and completing the email round-trip.

Request body:

{ "currentPassword": "correct-horse-battery", "newPassword": "new-secure-password" }
sequenceDiagram
  participant C as Client
  participant R as RESTHeart
  participant M as MongoDB

  C->>R: PATCH /auth/change-password {currentPassword, newPassword}
  R->>M: find user
  alt account has a password
    Note over R: bcrypt-verify currentPassword โ€” 401 if it doesn't match
  else account has no password yet (e.g. OAuth-only)
    Note over R: currentPassword not checked
  end
  Note over R: minimum length check on newPassword
  R->>M: hash + save newPassword
  M-->>R: OK
  R-->>C: 200 OK

Flow:

  1. Look up the caller’s own user document โ€” always the authenticated principal, never an arbitrary user (this endpoint requires authentication and never takes a target user in the request).

  2. If the account already has a password, verify currentPassword against the stored bcrypt hash โ€” 401 if it doesn’t match.

  3. If the account has no password yet, skip that check โ€” see "OAuth-only accounts" below.

  4. Enforce minimum password length on newPassword โ€” 400 otherwise.

  5. Hash and save the new password.

  6. Return 200 OK.

Important

OAuth-only accounts. A user who signed up via /auth/oauth/authorize/{provider} (see OAuth 2.0 Social Login) has no password at all โ€” restheart-accounts stores an empty, not null, password field for those accounts. currentPassword is still accepted in the request body for API-shape consistency, but it is only verified when the account actually has a password to check it against. This lets the same endpoint double as "set your first password" for OAuth-only accounts, with no current one to confirm, while every account that does have a password still needs the correct one.

This is safe without any extra guard because the check above (step 1) already scopes the whole request to the caller’s own document โ€” there is no currentPassword belonging to someone else that could be bypassed this way.

A frontend should not assume every account has a password: the user document carries a socialAuths array (one entry per linked OAuth provider โ€” provider, providerId, linkedAt) alongside password, populated on OAuth signup and preserved through GET /users/me. If socialAuths is non-empty, hint that the current-password field may be left blank rather than presenting it as always-required โ€” the account may never have had one.

Note
Does not invalidate other active sessions/JWTs โ€” out of scope for JWT-based auth unless a token-versioning/blacklist mechanism is added separately.

Frontend route: /account (settings page)

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

listTeamMembers(teamId)

GET /auth/team/members

Reads org’s members array, batch-fetches user profiles to denormalize display names

createTeam(userId, teamName)

POST /auth/teams

Creates org in orgs collection, adds user as owner, unconditionally switches active org

updateTeam(teamId, name, description)

PATCH /auth/team

Partial $set of name/description on the org document

deleteTeam(userId, teamId)

DELETE /auth/team

Atomic conditional delete of the org document, guarded by member-count; clears user’s org membership on success