Files
youtube-studio-flow/documentation/YouTube Studio Flow/01 - Architecture/06 - Authentication.md
T

7.1 KiB

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 <token> 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

{
  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": "<target>" } — 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 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 <token> by api-client.ts
  • 01 - System Overview
  • 02 - Environment Variables (development)