stage 1 of oauth removal, switch to sanctum pat tokens
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
authorizedFetch,
|
||||
clearOAuthSession,
|
||||
clearTokens,
|
||||
completeOAuthCallback,
|
||||
loadTokens,
|
||||
isAuthError,
|
||||
loadToken,
|
||||
registerAuthFailureHandler,
|
||||
startOAuthFlow,
|
||||
storePersonalAccessToken,
|
||||
} from './tokens';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
|
||||
import { invalidateTenantApiCache } from '../api';
|
||||
|
||||
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||
|
||||
@@ -18,30 +18,74 @@ export interface TenantProfile {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
email?: string | null;
|
||||
event_credits_balance?: number;
|
||||
event_credits_balance?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: TenantProfile | null;
|
||||
login: (redirectPath?: string) => void;
|
||||
logout: (options?: { redirect?: string }) => void;
|
||||
completeLogin: (params: URLSearchParams) => Promise<string | null>;
|
||||
refreshProfile: () => Promise<void>;
|
||||
logout: (options?: { redirect?: string }) => Promise<void>;
|
||||
applyToken: (token: string, abilities: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
function getCsrfToken(): string | undefined {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null;
|
||||
return meta?.content;
|
||||
}
|
||||
|
||||
async function exchangeSessionForToken(): Promise<{ token: string; abilities: string[] } | null> {
|
||||
const csrf = getCsrfToken();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/tenant-auth/exchange', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { token: string; abilities?: string[] };
|
||||
|
||||
if (!data?.token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token: data.token,
|
||||
abilities: Array.isArray(data.abilities) ? data.abilities : [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Session exchange failed', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [status, setStatus] = React.useState<AuthStatus>('loading');
|
||||
const [user, setUser] = React.useState<TenantProfile | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const profileQueryKey = React.useMemo(() => ['tenantProfile'], []);
|
||||
|
||||
const handleAuthFailure = React.useCallback(() => {
|
||||
clearTokens();
|
||||
invalidateTenantApiCache();
|
||||
queryClient.removeQueries({ queryKey: profileQueryKey });
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}, []);
|
||||
}, [profileQueryKey, queryClient]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = registerAuthFailureHandler(handleAuthFailure);
|
||||
@@ -49,92 +93,133 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}, [handleAuthFailure]);
|
||||
|
||||
const refreshProfile = React.useCallback(async () => {
|
||||
setStatus('loading');
|
||||
|
||||
try {
|
||||
const response = await authorizedFetch('/api/v1/tenant/me');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load profile');
|
||||
}
|
||||
const profile = (await response.json()) as TenantProfile;
|
||||
setUser(profile);
|
||||
const data = await queryClient.fetchQuery({
|
||||
queryKey: profileQueryKey,
|
||||
queryFn: async () => {
|
||||
const response = await authorizedFetch('/api/v1/tenant-auth/me', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch tenant profile');
|
||||
}
|
||||
|
||||
return (await response.json()) as {
|
||||
user: TenantProfile | null;
|
||||
tenant?: Record<string, unknown> | null;
|
||||
abilities: string[];
|
||||
};
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
cacheTime: 1000 * 60 * 30,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const composed: TenantProfile | null = data.user && data.tenant
|
||||
? { ...data.user, ...data.tenant }
|
||||
: data.user;
|
||||
|
||||
setUser(composed ?? null);
|
||||
setStatus('authenticated');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to refresh profile', error);
|
||||
handleAuthFailure();
|
||||
|
||||
if (isAuthError(error)) {
|
||||
handleAuthFailure();
|
||||
} else {
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}, [handleAuthFailure]);
|
||||
}, [handleAuthFailure, profileQueryKey, queryClient]);
|
||||
|
||||
const applyToken = React.useCallback(async (token: string, abilities: string[]) => {
|
||||
storePersonalAccessToken(token, abilities);
|
||||
await refreshProfile();
|
||||
}, [refreshProfile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.has('reset-auth') || window.location.pathname === ADMIN_LOGIN_PATH) {
|
||||
clearTokens();
|
||||
clearOAuthSession();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) {
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
const bootstrap = async () => {
|
||||
const stored = loadToken();
|
||||
if (stored) {
|
||||
try {
|
||||
await refreshProfile();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Stored token bootstrap failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshProfile().catch(() => {
|
||||
// refreshProfile already handled failures.
|
||||
const exchanged = await exchangeSessionForToken();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (exchanged) {
|
||||
await applyToken(exchanged.token, exchanged.abilities);
|
||||
return;
|
||||
}
|
||||
|
||||
handleAuthFailure();
|
||||
};
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[Auth] Failed to bootstrap authentication', error);
|
||||
}
|
||||
handleAuthFailure();
|
||||
});
|
||||
}, [handleAuthFailure, refreshProfile]);
|
||||
|
||||
const login = React.useCallback((redirectPath?: string) => {
|
||||
const sanitizedTarget = redirectPath && redirectPath.trim() !== '' ? redirectPath : ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
const target = sanitizedTarget.startsWith('/') ? sanitizedTarget : `/${sanitizedTarget}`;
|
||||
startOAuthFlow(target);
|
||||
}, []);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applyToken, handleAuthFailure, refreshProfile]);
|
||||
|
||||
const logout = React.useCallback(async ({ redirect }: { redirect?: string } = {}) => {
|
||||
try {
|
||||
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content;
|
||||
await fetch('/logout', {
|
||||
await authorizedFetch('/api/v1/tenant-auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to notify backend about logout', error);
|
||||
console.warn('[Auth] API logout failed', error);
|
||||
}
|
||||
} finally {
|
||||
clearTokens();
|
||||
clearOAuthSession();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
try {
|
||||
const csrf = getCsrfToken();
|
||||
await fetch('/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Session logout failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
handleAuthFailure();
|
||||
|
||||
if (redirect) {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const completeLogin = React.useCallback(
|
||||
async (params: URLSearchParams) => {
|
||||
setStatus('loading');
|
||||
try {
|
||||
const redirectTarget = await completeOAuthCallback(params);
|
||||
await refreshProfile();
|
||||
return redirectTarget;
|
||||
} catch (error) {
|
||||
handleAuthFailure();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[handleAuthFailure, refreshProfile]
|
||||
);
|
||||
}, [handleAuthFailure]);
|
||||
|
||||
const value = React.useMemo<AuthContextValue>(
|
||||
() => ({ status, user, login, logout, completeLogin, refreshProfile }),
|
||||
[status, user, login, logout, completeLogin, refreshProfile]
|
||||
() => ({ status, user, refreshProfile, logout, applyToken }),
|
||||
[status, user, refreshProfile, logout, applyToken]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -1,42 +1,151 @@
|
||||
import { generateCodeChallenge, generateCodeVerifier, generateState } from './pkce';
|
||||
import { decodeStoredTokens } from './utils';
|
||||
import { ADMIN_AUTH_CALLBACK_PATH } from '../constants';
|
||||
const TOKEN_STORAGE_KEY = 'tenant_admin.token.v1';
|
||||
const TOKEN_SESSION_KEY = 'tenant_admin.token.session.v1';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'tenant_oauth_tokens.v1';
|
||||
const CODE_VERIFIER_KEY = 'tenant_oauth_code_verifier';
|
||||
const STATE_KEY = 'tenant_oauth_state';
|
||||
const REDIRECT_KEY = 'tenant_oauth_redirect';
|
||||
const TOKEN_ENDPOINT = '/api/v1/oauth/token';
|
||||
const AUTHORIZE_ENDPOINT = '/api/v1/oauth/authorize';
|
||||
const SCOPES = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
|
||||
|
||||
function getClientId(): string {
|
||||
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined;
|
||||
if (!clientId) {
|
||||
throw new Error('VITE_OAUTH_CLIENT_ID is not configured');
|
||||
}
|
||||
return clientId;
|
||||
}
|
||||
|
||||
function buildRedirectUri(): string {
|
||||
return new URL(ADMIN_AUTH_CALLBACK_PATH, window.location.origin).toString();
|
||||
}
|
||||
export type StoredToken = {
|
||||
accessToken: string;
|
||||
abilities: string[];
|
||||
issuedAt: number;
|
||||
};
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(public code: 'unauthenticated' | 'unauthorized' | 'invalid_state' | 'token_exchange_failed', message?: string) {
|
||||
constructor(public code: 'unauthenticated' | 'unauthorized', message?: string) {
|
||||
super(message ?? code);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthError(value: unknown): value is AuthError {
|
||||
return value instanceof AuthError;
|
||||
let cachedToken: StoredToken | null = null;
|
||||
|
||||
function decodeStoredToken(raw: string | null): StoredToken | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StoredToken;
|
||||
if (!parsed || typeof parsed.accessToken !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: parsed.accessToken,
|
||||
abilities: Array.isArray(parsed.abilities) ? parsed.abilities : [],
|
||||
issuedAt: typeof parsed.issuedAt === 'number' ? parsed.issuedAt : Date.now(),
|
||||
} satisfies StoredToken;
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to decode stored token', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readTokenFromStorage(): StoredToken | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionValue = window.sessionStorage.getItem(TOKEN_SESSION_KEY);
|
||||
const fromSession = decodeStoredToken(sessionValue);
|
||||
if (fromSession) {
|
||||
return fromSession;
|
||||
}
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to read session stored token', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const persistentValue = window.localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
return decodeStoredToken(persistentValue);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to read persisted token', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadToken(): StoredToken | null {
|
||||
if (cachedToken) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
const stored = readTokenFromStorage();
|
||||
cachedToken = stored;
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
function persistToken(token: StoredToken): void {
|
||||
if (typeof window === 'undefined') {
|
||||
cachedToken = token;
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(token);
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(TOKEN_STORAGE_KEY, serialized);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to persist tenant token to localStorage', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(TOKEN_SESSION_KEY, serialized);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to persist tenant token to sessionStorage', error);
|
||||
}
|
||||
}
|
||||
|
||||
cachedToken = token;
|
||||
}
|
||||
|
||||
export function storePersonalAccessToken(accessToken: string, abilities: string[]): StoredToken {
|
||||
const stored: StoredToken = {
|
||||
accessToken,
|
||||
abilities,
|
||||
issuedAt: Date.now(),
|
||||
};
|
||||
|
||||
persistToken(stored);
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
cachedToken = null;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to remove stored tenant token', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.removeItem(TOKEN_SESSION_KEY);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to remove session tenant token', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type AuthFailureHandler = () => void;
|
||||
const authFailureHandlers = new Set<AuthFailureHandler>();
|
||||
|
||||
function notifyAuthFailure() {
|
||||
function notifyAuthFailure(): void {
|
||||
authFailureHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler();
|
||||
@@ -53,214 +162,30 @@ export function registerAuthFailureHandler(handler: AuthFailureHandler): () => v
|
||||
};
|
||||
}
|
||||
|
||||
export interface StoredTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scope?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export function loadTokens(): StoredTokens | null {
|
||||
const raw = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
const stored = decodeStoredTokens<StoredTokens>(raw);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stored.accessToken || !stored.refreshToken || !stored.expiresAt) {
|
||||
clearTokens();
|
||||
return null;
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function saveTokens(response: TokenResponse, clientId: string = getClientId()): StoredTokens {
|
||||
const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000;
|
||||
const stored: StoredTokens = {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
expiresAt,
|
||||
scope: response.scope,
|
||||
clientId,
|
||||
};
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored));
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function ensureAccessToken(): Promise<string> {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'No tokens available');
|
||||
}
|
||||
|
||||
if (Date.now() < tokens.expiresAt) {
|
||||
return tokens.accessToken;
|
||||
}
|
||||
|
||||
return refreshAccessToken(tokens);
|
||||
}
|
||||
|
||||
async function refreshAccessToken(tokens: StoredTokens): Promise<string> {
|
||||
const clientId = tokens.clientId ?? getClientId();
|
||||
|
||||
if (!tokens.refreshToken) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'Missing refresh token');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: tokens.refreshToken,
|
||||
client_id: clientId,
|
||||
});
|
||||
|
||||
const response = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[Auth] Refresh token request failed', response.status);
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'Refresh token invalid');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TokenResponse;
|
||||
const stored = saveTokens(data, clientId);
|
||||
return stored.accessToken;
|
||||
export function isAuthError(value: unknown): value is AuthError {
|
||||
return value instanceof AuthError;
|
||||
}
|
||||
|
||||
export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||
const token = await ensureAccessToken();
|
||||
const stored = loadToken();
|
||||
if (!stored) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'No active tenant admin token');
|
||||
}
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
headers.set('Authorization', `Bearer ${stored.accessToken}`);
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(input, { ...init, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
clearTokens();
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthorized', 'Access token rejected');
|
||||
throw new AuthError('unauthorized', 'Token rejected by API');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function startOAuthFlow(redirectPath?: string): Promise<void> {
|
||||
const verifier = generateCodeVerifier();
|
||||
const state = generateState();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
|
||||
sessionStorage.setItem(CODE_VERIFIER_KEY, verifier);
|
||||
sessionStorage.setItem(STATE_KEY, state);
|
||||
localStorage.setItem(CODE_VERIFIER_KEY, verifier);
|
||||
localStorage.setItem(STATE_KEY, state);
|
||||
if (redirectPath) {
|
||||
sessionStorage.setItem(REDIRECT_KEY, redirectPath);
|
||||
localStorage.setItem(REDIRECT_KEY, redirectPath);
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[Auth] PKCE store', { state, verifier, redirectPath });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: getClientId(),
|
||||
redirect_uri: buildRedirectUri(),
|
||||
scope: SCOPES,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
window.location.href = `${AUTHORIZE_ENDPOINT}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function completeOAuthCallback(params: URLSearchParams): Promise<string | null> {
|
||||
if (params.get('error')) {
|
||||
throw new AuthError('token_exchange_failed', params.get('error_description') ?? params.get('error') ?? 'OAuth error');
|
||||
}
|
||||
|
||||
const code = params.get('code');
|
||||
const returnedState = params.get('state');
|
||||
const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY) ?? localStorage.getItem(CODE_VERIFIER_KEY);
|
||||
const expectedState = sessionStorage.getItem(STATE_KEY) ?? localStorage.getItem(STATE_KEY);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[Auth] PKCE debug', { returnedState, expectedState, hasVerifier: !!verifier, params: params.toString() });
|
||||
}
|
||||
|
||||
if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) {
|
||||
clearOAuthSession();
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('invalid_state', 'PKCE state mismatch');
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
localStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
localStorage.removeItem(STATE_KEY);
|
||||
|
||||
const clientId = getClientId();
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: buildRedirectUri(),
|
||||
code_verifier: verifier,
|
||||
});
|
||||
|
||||
const response = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearOAuthSession();
|
||||
console.error('[Auth] Authorization code exchange failed', response.status);
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TokenResponse;
|
||||
saveTokens(data, clientId);
|
||||
|
||||
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
|
||||
if (redirectTarget) {
|
||||
sessionStorage.removeItem(REDIRECT_KEY);
|
||||
localStorage.removeItem(REDIRECT_KEY);
|
||||
} else {
|
||||
localStorage.removeItem(REDIRECT_KEY);
|
||||
}
|
||||
|
||||
return redirectTarget;
|
||||
}
|
||||
|
||||
export function clearOAuthSession(): void {
|
||||
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
sessionStorage.removeItem(REDIRECT_KEY);
|
||||
localStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
localStorage.removeItem(STATE_KEY);
|
||||
localStorage.removeItem(REDIRECT_KEY);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user