7.1 KiB
Authentication
Flow
- User clicks "Sign in with Google" → frontend redirects to
GET /auth/google - Backend redirects to Google OAuth consent screen
- Google redirects to
GET /auth/google/callbackwith auth code - Backend exchanges code for tokens, upserts
Userrecord, then branches on team membership (see below) - Backend issues JWT access token (short-lived) + refresh token (long-lived)
- Frontend stores tokens;
api-client.tsattachesAuthorization: Bearer <token>to all requests - On 401:
api-client.tsautomatically callsPOST /auth/refreshand retries the original request
First Login vs. Returning User
The OAuth callback handler (AuthService.upsertGoogleUser) checks whether the authenticating user already has any TeamMember rows.
New user (no team memberships)
A full onboarding sequence runs automatically — no separate onboarding step or UI flow exists:
- YouTube API is called with the login OAuth tokens to fetch the user's own channel (
channels.list?mine=true), retrieving the YouTube channel ID, channel name, and uploads playlist ID. - A
Teamis created using the user's display name as the team name, with a URL-safe slug derived from it. - A
Channelis created and linked to the team, with the YouTube OAuth tokens stored AES-256 encrypted. - A
TeamMemberrow is created linking the user to the team with roleOWNER. - The JWT is issued with this new team's ID.
This means the first Google login is both account creation and channel connection in one step. There is no separate "connect your channel" screen.
Returning user (already has team memberships)
- The user's existing
TeamMemberrows are loaded, ordered bycreatedAtascending. - The first membership's
teamIdandroleare used for the JWT. - The YouTube access token is refreshed on all
Channelrows whereconnectedBy = user.id. The refresh token is only overwritten if Google returns a new one (which only happens on first auth or forced re-consent).
Invited user
Invitations (POST /teams/:teamId/members) create a TeamMember row immediately. The invitee must have already logged in at least once — invite lookup is by User.email, and if no User row exists for that email the invite throws a 404. There is no pending-invite or email-notification system.
When an invited user subsequently logs in:
- They already have at least one
TeamMemberrow (the invite). - They fall into the returning user path — no new team is created.
- If this is their very first login, their
Userrecord is created byupsertGoogleUserbefore the membership check, so the invite is found correctly.
JWT Payload
{
sub: string; // User.id
email: string;
teamId: string; // Active team for this session
teamRole: TeamRole;
}
On first login the active team is the user's primary team (earliest TeamMember.createdAt). After calling POST /auth/switch-team the returned token carries the new team's ID and role.
Team Switching
A user who belongs to multiple teams can switch the active team without logging out:
- Call
GET /teams/mineto get the list of teams the user belongs to. - Call
POST /auth/switch-teamwith{ "teamId": "<target>" }— returns{ "accessToken": "..." }. - Replace the stored access token with the new one. All subsequent API calls are now scoped to the new team.
The backend validates that the user is actually a member of the requested team before issuing the new token.
Current limitation: The frontend has no UI for this. POST /auth/switch-team and GET /teams/mine are not called anywhere in the frontend codebase. Team switching is currently only possible via direct API calls.
Guards
| Guard | Usage |
|---|---|
JwtAuthGuard |
Applied to all protected routes via @UseGuards(JwtAuthGuard) |
RolesGuard |
Applied alongside JwtAuthGuard; uses @Roles(TeamRole.EDITOR) decorator |
Team Roles
| Role | Permissions |
|---|---|
OWNER |
All permissions |
ADMIN |
All permissions except ownership transfer |
EDITOR |
Create/update/delete content |
REVIEWER |
Read only (see note below) |
READONLY |
Read only |
OWNER is assigned automatically when a team is created (first Google login). It cannot be assigned via the invite or role-update endpoints — both reject role: OWNER with 403 Forbidden. There is no ownership transfer mechanism. The OWNER's role cannot be changed by anyone, and the OWNER cannot be removed from the team.
No endpoint is gated exclusively to OWNER — every @Roles guard that lists OWNER also lists ADMIN. Because the guard uses priority ordering, ADMIN users have identical feature access. If an OWNER's Google account becomes inaccessible, ADMIN members can continue using the application without restriction. The only consequence is that the orphaned TeamMember row with role: OWNER cannot be removed via the API (it would require direct DB access). Since the YouTube channel is bound to the same Google account as the OWNER, a lost Google account means the channel itself is also inaccessible — an in-app ownership transfer would not recover YouTube access.
REVIEWER and READONLY are currently functionally identical. The
REVIEWERrole sits at priority 2 in theRolesGuardhierarchy (aboveREADONLYat 1), but no endpoint specifiesREVIEWERas a minimum role. The intended "comment" capability does not exist — there is no comment model, no comment endpoints, and no UI for comments. Until a comment system is built, assigningREVIEWERgrants exactly the same access asREADONLY. See the backlog.
sf_session Cookie
sf_session is a frontend-only routing flag — it is not a backend session and carries no auth data. It is set to 1 by the frontend via document.cookie after a successful login or token refresh, and cleared on logout or failed refresh.
Its sole purpose is to give Next.js middleware.ts something to check. Middleware runs on the edge before the React app hydrates and cannot access Zustand or localStorage (where the real JWT lives). Reading this cookie is the only way the middleware can distinguish authenticated from unauthenticated requests and redirect accordingly.
The backend does not read or set sf_session. The backend sets a refresh_token httpOnly cookie for token refresh purposes.
Limitation: sf_session is a presence flag, not a validity check. If the cookie persists after a token is revoked (e.g. due to a crash before the clear runs), middleware lets the request through and the client gets 401s from the API, which then redirects to /login client-side. This is acceptable given the current architecture.
Token Security
- YouTube OAuth tokens are AES-256 encrypted in the database using
TOKEN_ENCRYPTION_KEY(must be exactly 32 characters) - JWT signing uses separate
JWT_SECRETandJWT_REFRESH_SECRET - The backend sets a
refresh_tokenhttpOnly cookie on login and clears it on logout - Access tokens are stored in Zustand (persisted to
localStorage) and attached to every request asAuthorization: Bearer <token>byapi-client.ts
Related
- 01 - System Overview
- 02 - Environment Variables (development)