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:
- Verifies the Google token using Google's public JWKS
- Creates the user in Supabase if they don't exist
- 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:
- Get Google Identity Token from Apps Script server
- Exchange for Supabase session via backend
- Create session object
Backend: Token Verification
The /api/auth/google endpoint verifies the Google token and issues a Supabase JWT:
- Verify Google ID token
- Validate email matches the token
- Check if email is verified
- Create or fetch user in Supabase
- Generate Supabase-compatible JWT
- Fetch subscription data for the user
- 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
| Claim | Value | Purpose |
|---|---|---|
sub | User UUID | Used by auth.uid() in RLS |
role | authenticated | Enables RLS policies |
aud | authenticated | Required by Supabase |
iat | Current timestamp | Token issue time |
exp | +1 hour | Token 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
| Event | Action |
|---|---|
| Add-on opens | Automatic authentication via useAuth |
| Token expires (1h) | Auto-refresh 5 minutes before expiry |
| User closes sidebar | Session lost (re-authenticates on next open) |
| User logs out of Google | getIdentityToken() fails |
Error Handling
Common authentication errors:
| Error | Cause | Solution |
|---|---|---|
| "Invalid or expired token" | Wrong OAuth Client ID | Check GOOGLE_*_CLIENT_ID env vars |
| "Email not verified" | New Google account | User must verify email |
| "Failed to get auth payload" | Missing scopes | Check appsscript.json includes openid |
| "Network error" | Backend unreachable | Check API_BASE_URL and CORS |