# Authentication ## Flow 1. User clicks "Sign in with Google" → frontend redirects to `GET /auth/google` 2. Backend redirects to Google OAuth consent screen 3. Google redirects to `GET /auth/google/callback` with auth code 4. Backend exchanges code for tokens, upserts `User` record, then branches on team membership (see below) 5. Backend issues JWT access token (short-lived) + refresh token (long-lived) 6. Frontend stores tokens; `api-client.ts` attaches `Authorization: Bearer ` to all requests 7. On 401: `api-client.ts` automatically calls `POST /auth/refresh` and 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: 1. 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. 2. A `Team` is created using the user's display name as the team name, with a URL-safe slug derived from it. 3. A `Channel` is created and linked to the team, with the YouTube OAuth tokens stored AES-256 encrypted. 4. A `TeamMember` row is created linking the user to the team with role `OWNER`. 5. 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) 1. The user's existing `TeamMember` rows are loaded, ordered by `createdAt` ascending. 2. The first membership's `teamId` and `role` are used for the JWT. 3. The YouTube access token is refreshed on all `Channel` rows where `connectedBy = 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 `TeamMember` row (the invite). - They fall into the **returning user** path — no new team is created. - If this is their very first login, their `User` record is created by `upsertGoogleUser` before the membership check, so the invite is found correctly. ## JWT Payload ```typescript { 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: 1. Call `GET /teams/mine` to get the list of teams the user belongs to. 2. Call `POST /auth/switch-team` with `{ "teamId": "" }` — returns `{ "accessToken": "..." }`. 3. 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 `REVIEWER` role sits at priority 2 in the `RolesGuard` hierarchy (above `READONLY` at 1), but no endpoint specifies `REVIEWER` as 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, assigning `REVIEWER` grants exactly the same access as `READONLY`. 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_SECRET` and `JWT_REFRESH_SECRET` - The backend sets a `refresh_token` httpOnly cookie on login and clears it on logout - Access tokens are stored in Zustand (persisted to `localStorage`) and attached to every request as `Authorization: Bearer ` by `api-client.ts` ## Related - [[01 - System Overview]] - [[02 - Environment Variables]] (development)