All files / services/auth authService.ts

96.61% Statements 171/177
90% Branches 36/40
100% Functions 13/13
96.61% Lines 171/177

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 1781x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x     13x 13x 1x 10x 10x 10x 10x 10x 1x 10x 10x 10x 10x 10x 10x 10x 10x 4x 4x 10x 4x 4x 10x 10x 1x 7x 7x 7x 7x     7x 7x 1x 1x 6x 6x 1x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x     7x 7x 7x 7x 7x 7x 7x 7x 1x 6x 6x 6x 1x 557x 557x 557x 557x 1x 1x 6x 6x 6x 6x 6x 2x 1x 1x 4x 4x 4x 4x 4x 1x 1x 1x 1x 1x 1x 279x 279x 279x 6x 4x 4x 4x 4x 4x 2x 2x 2x 2x 2x 2x 279x 1x 1x 276x 276x 276x 1x 1x 275x 276x 276x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 1x 2x 2x 2x 2x  
import { AuthError, type AuthenticatedUser, type StoredAuth } from "../../types/auth";
 
declare const WXT_GOOGLE_OAUTH_CLIENT_ID: string;
 
/**
 * Google sign-in via chrome.identity.launchWebAuthFlow running an OIDC
 * implicit id_token flow (research.md R1 — getAuthToken returns an access
 * token, which the server-side JWKS verification cannot accept).
 *
 * The session lives in chrome.storage.local so it survives browser restarts;
 * silent renewal (interactive:false) keeps it alive for up to ~30 days from
 * the interactive sign-in (FR-014a). The client only *decodes* the token for
 * display/expiry — verification is exclusively server-side (FR-003).
 */
 
export const AUTH_SESSION_KEY = "auth:session";
export const NOT_AUTHORIZED_KEY = "auth:notAuthorized";
 
/** ~30 days: past this, silent renewal stops and interactive sign-in is required. */
export const SESSION_HORIZON_MS = 30 * 24 * 60 * 60 * 1000;
 
const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
/** Renew slightly before exp so in-flight requests don't race expiry. */
const EXPIRY_SKEW_MS = 60_000;
 
interface IdTokenClaims {
  sub: string;
  email: string;
  exp: number;
  nonce?: string;
}
 
function decodeIdToken(idToken: string): IdTokenClaims {
  const parts = idToken.split(".");
  if (parts.length !== 3) throw new Error("Malformed ID token");
  const payloadJson = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
  const claims = JSON.parse(payloadJson) as Partial<IdTokenClaims>;
  if (
    typeof claims.sub !== "string" ||
    typeof claims.email !== "string" ||
    typeof claims.exp !== "number"
  ) {
    throw new Error("ID token is missing required claims");
  }
  return claims as IdTokenClaims;
}
 
function freshNonce(): string {
  const bytes = new Uint8Array(16);
  crypto.getRandomValues(bytes);
  return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
}
 
function buildAuthUrl(nonce: string, options: { loginHint?: string; silent?: boolean }): string {
  const url = new URL(AUTH_ENDPOINT);
  url.searchParams.set("client_id", WXT_GOOGLE_OAUTH_CLIENT_ID);
  url.searchParams.set("response_type", "id_token");
  url.searchParams.set("redirect_uri", chrome.identity.getRedirectURL());
  url.searchParams.set("scope", "openid email");
  url.searchParams.set("nonce", nonce);
  if (options.silent) {
    url.searchParams.set("prompt", "none");
  }
  if (options.loginHint) {
    url.searchParams.set("login_hint", options.loginHint);
  }
  return url.toString();
}
 
function extractIdToken(redirectUrl: string, expectedNonce: string): string {
  const hash = new URL(redirectUrl).hash.replace(/^#/, "");
  const idToken = new URLSearchParams(hash).get("id_token");
  if (!idToken) {
    throw new AuthError("sign-in-failed", "Google did not return an ID token.");
  }
  const claims = decodeIdToken(idToken);
  if (claims.nonce !== expectedNonce) {
    throw new AuthError("sign-in-failed", "Sign-in response failed the nonce check.");
  }
  return idToken;
}
 
async function runAuthFlow(options: {
  interactive: boolean;
  loginHint?: string;
}): Promise<{ idToken: string; user: AuthenticatedUser; expiresAt: number }> {
  const nonce = freshNonce();
  const url = buildAuthUrl(nonce, {
    loginHint: options.loginHint,
    silent: !options.interactive,
  });
  const redirectUrl = await chrome.identity.launchWebAuthFlow({
    url,
    interactive: options.interactive,
  });
  if (!redirectUrl) {
    throw new AuthError("sign-in-canceled", "Sign-in was canceled.");
  }
  const idToken = extractIdToken(redirectUrl, nonce);
  const claims = decodeIdToken(idToken);
  return {
    idToken,
    user: { sub: claims.sub, email: claims.email },
    expiresAt: claims.exp * 1000,
  };
}
 
async function persist(auth: StoredAuth): Promise<void> {
  await chrome.storage.local.set({ [AUTH_SESSION_KEY]: auth });
}
 
export async function getStoredAuth(): Promise<StoredAuth | null> {
  const data = await chrome.storage.local.get(AUTH_SESSION_KEY);
  return (data[AUTH_SESSION_KEY] as StoredAuth | undefined) ?? null;
}
 
/** Interactive sign-in: anchors a new ~30-day session (sets signedInAt). */
export async function signIn(): Promise<StoredAuth> {
  let result;
  try {
    result = await runAuthFlow({ interactive: true });
  } catch (err) {
    if (err instanceof AuthError) throw err;
    throw new AuthError("sign-in-canceled", "Sign-in was canceled.");
  }
  const auth: StoredAuth = { ...result, signedInAt: Date.now() };
  await chrome.storage.local.remove(NOT_AUTHORIZED_KEY);
  await persist(auth);
  return auth;
}
 
/**
 * Non-interactive renewal. Never throws: returns null when there is no
 * session, the ~30-day horizon has passed (FR-014a), or Google refuses the
 * silent flow — callers fall back to the sign-in gate.
 */
export async function signInSilently(): Promise<StoredAuth | null> {
  const stored = await getStoredAuth();
  if (!stored) return null;
  if (Date.now() - stored.signedInAt > SESSION_HORIZON_MS) return null;
  try {
    const result = await runAuthFlow({
      interactive: false,
      loginHint: stored.user.email,
    });
    const auth: StoredAuth = { ...result, signedInAt: stored.signedInAt };
    await persist(auth);
    return auth;
  } catch {
    return null;
  }
}
 
/** Fresh Bearer token for a request, renewing silently when needed. */
export async function getIdToken(): Promise<string | null> {
  const stored = await getStoredAuth();
  if (stored && stored.expiresAt - EXPIRY_SKEW_MS > Date.now()) {
    return stored.idToken;
  }
  const renewed = await signInSilently();
  return renewed?.idToken ?? null;
}
 
export async function signOut(): Promise<void> {
  await chrome.storage.local.remove([AUTH_SESSION_KEY, NOT_AUTHORIZED_KEY]);
}
 
/** Server said 403: end the session and remember the invitation state. */
export async function markNotAuthorized(): Promise<void> {
  await chrome.storage.local.remove(AUTH_SESSION_KEY);
  await chrome.storage.local.set({ [NOT_AUTHORIZED_KEY]: true });
}
 
export async function isNotAuthorized(): Promise<boolean> {
  const data = await chrome.storage.local.get(NOT_AUTHORIZED_KEY);
  return data[NOT_AUTHORIZED_KEY] === true;
}