ShipAddons

Authentication

Google Identity Token flow and JWT session management

ShipAddons uses a server-mediated authentication flow that exchanges Google Identity Tokens for Supabase JWTs.

Authentication Flow

┌─────────────────┐     1. getIdentityToken()     ┌─────────────────┐
│   Apps Script   │◄─────────────────────────────►│     Google      │
│    (Server)     │                               │    (Identity)   │
└────────┬────────┘                               └─────────────────┘

         │ 2. Return token

┌─────────────────┐     3. POST /api/auth/google  ┌─────────────────┐
│   React Client  │──────────────────────────────►│   Next.js API   │
│    (Sidebar)    │                               │                 │
└────────┬────────┘                               └────────┬────────┘
         │                                                 │
         │                                                 │ 4. Verify token
         │                                                 ▼
         │                                        ┌─────────────────┐
         │                                        │  Google JWKS    │
         │                                        └────────┬────────┘
         │                                                 │
         │                                                 │ 5. Create/update user
         │                                                 ▼
         │                                        ┌─────────────────┐
         │                                        │    Supabase     │
         │                                        └────────┬────────┘
         │                                                 │
         │              6. Return Supabase JWT             │
         │◄────────────────────────────────────────────────┘


┌─────────────────┐
│  Store session  │
│  (State + API)  │
└─────────────────┘

Why Server-Mediated Auth

Google Identity Tokens issued by Apps Script cannot be directly verified by Supabase. The backend acts as a trusted intermediary:

  1. Verifies the Google token using Google's public JWKS
  2. Creates the user in Supabase if they don't exist
  3. Generates a Supabase-compatible JWT that RLS understands

This approach ensures:

  • Users are authenticated by Google (trusted identity provider)
  • Supabase RLS works correctly with custom JWTs
  • No client-side secrets are exposed

Apps Script: Getting the Identity Token

The server-side Apps Script code obtains the Google Identity Token:

// addon/src/server/auth.ts

export function getGoogleIdentityToken(): string | null {
  try {
    const token = ScriptApp.getIdentityToken();
    if (!token) {
      console.error("Identity token is empty");
      return null;
    }
    return token;
  } catch (e) {
    console.error("Failed to get identity token:", e);
    return null;
  }
}

export function getAuthPayload(): { token: string; email: string } | null {
  const token = getGoogleIdentityToken();
  if (!token) return null;

  const email = Session.getActiveUser().getEmail();
  if (!email) return null;

  return { token, email };
}

The openid scope in appsscript.json is required for getIdentityToken() to work.

React Client: Auth Hook

The useAuth hook manages authentication state:

  1. Get Google Identity Token from Apps Script server
  2. Exchange for Supabase session via backend
  3. Create session object

Backend: Token Verification

The /api/auth/google endpoint verifies the Google token and issues a Supabase JWT:

  1. Verify Google ID token
  2. Validate email matches the token
  3. Check if email is verified
  4. Create or fetch user in Supabase
  5. Generate Supabase-compatible JWT
  6. Fetch subscription data for the user
  7. Return session data

JWT Generation

Supabase JWTs are signed with the HS256 algorithm using the legacy JWT secret:

// web/lib/jwt.ts

export async function generateSupabaseJWT(
  payload: SupabaseJWTPayload
): Promise<string> {
  const config = getJWTConfig();
  const now = Math.floor(Date.now() / 1000);

  const jwt = new SignJWT({
    ...payload,
    role: payload.role || "authenticated",
    aud: payload.aud || "authenticated",
  })
    .setIssuedAt(now)
    .setExpirationTime(now + 3600); // 1 hour

  if (config.algorithm === "ES256") {
    const privateKey = await getPrivateKey();
    return jwt
      .setProtectedHeader({
        alg: "ES256",
        typ: "JWT",
        kid: config.kid,
      })
      .sign(privateKey);
  } else {
    const symmetricKey = getSymmetricKey();
    return jwt
      .setProtectedHeader({ alg: "HS256", typ: "JWT" })
      .sign(symmetricKey);
  }
}

Required JWT Claims

ClaimValuePurpose
subUser UUIDUsed by auth.uid() in RLS
roleauthenticatedEnables RLS policies
audauthenticatedRequired by Supabase
iatCurrent timestampToken issue time
exp+1 hourToken expiration

Auth Context

The AuthProvider makes auth state available throughout the add-on:

// addon/src/client/context/AuthContext.tsx

export function AuthProvider({ children }: AuthProviderProps) {
  const auth = useAuth();

  return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}

// HOC for protected components
export function withAuth<P extends object>(
  WrappedComponent: React.ComponentType<P>
): React.FC<P> {
  return function WithAuthComponent(props: P) {
    const { isLoading, error, isAuthenticated } = useAuthContext();

    if (isLoading) {
      return (
        <div className="flex items-center justify-center p-8">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
        </div>
      );
    }

    if (error) {
      return (
        <div className="flex flex-col items-center justify-center p-8 text-center">
          <div className="text-red-500 mb-2">Authentication Error</div>
          <div className="text-sm text-gray-600">{error.message}</div>
        </div>
      );
    }

    if (!isAuthenticated) {
      return (
        <div className="flex flex-col items-center justify-center p-8 text-center">
          <div className="text-gray-600">Not authenticated</div>
        </div>
      );
    }

    return <WrappedComponent {...props} />;
  };
}

Making Authenticated Requests

Use the access token for API requests:

const { session } = useAuth();

const response = await fetch(`${API_BASE_URL}/api/user`, {
  headers: {
    Authorization: `Bearer ${session.accessToken}`,
    "Content-Type": "application/json",
  },
});

Token Lifecycle

EventAction
Add-on opensAutomatic authentication via useAuth
Token expires (1h)Auto-refresh 5 minutes before expiry
User closes sidebarSession lost (re-authenticates on next open)
User logs out of GooglegetIdentityToken() fails

Error Handling

Common authentication errors:

ErrorCauseSolution
"Invalid or expired token"Wrong OAuth Client IDCheck GOOGLE_*_CLIENT_ID env vars
"Email not verified"New Google accountUser must verify email
"Failed to get auth payload"Missing scopesCheck appsscript.json includes openid
"Network error"Backend unreachableCheck API_BASE_URL and CORS

On this page