google login works

This commit is contained in:
2026-01-21 03:09:38 +10:30
parent e7c2c873c2
commit e854bf801e
3 changed files with 163 additions and 239 deletions
+55 -106
View File
@@ -131,60 +131,54 @@ export interface WebAuthnLoginCompleteResponse {
expires_at: string; expires_at: string;
} }
export interface ExternalProviderListResponse { // External Auth Types
export interface ExternalProvider {
id: string;
name: string;
type: string;
is_configured: boolean;
is_active: boolean;
settings: {
requires_domain: boolean;
supports_refresh_tokens: boolean;
};
}
export interface ExternalProvidersResponse {
providers: ExternalProvider[]; providers: ExternalProvider[];
} }
export interface LinkedAccount {
id: string;
provider_type: string;
provider_user_id: string;
email: string | null;
name: string | null;
picture: string | null;
verified: boolean;
linked_at: string | null;
last_used_at: string | null;
}
export interface LinkedAccountsResponse { export interface LinkedAccountsResponse {
linked_accounts: LinkedAccount[]; linked_accounts: LinkedAccount[];
unlink_available: boolean; unlink_available: boolean;
} }
export interface ExternalProvider {
id: ExternalProviderId;
name: string;
is_active: boolean;
scopes: string[];
}
export interface ExternalProviderConfig {
client_id?: string;
client_secret?: string;
auth_url: string;
token_url: string;
userinfo_url: string;
scopes: string[];
redirect_uris: string[];
is_active: boolean;
settings?: Record<string, unknown>;
}
export interface LinkedAccount {
id: string;
provider_type: ExternalProviderId;
name: string;
email: string;
picture?: string;
provider_user_id?: string;
linked_at: string;
last_used_at?: string;
verified?: boolean;
}
export interface OAuthAuthorizeResponse { export interface OAuthAuthorizeResponse {
authorization_url: string; authorization_url: string;
state: string; state: string;
} }
export interface OAuthCallbackResponse { export interface OAuthCallbackResponse {
success: boolean; token: string;
token?: string; expires_in: number;
user?: User; token_type: string;
expires_in?: number; user: User;
requires_mfa?: boolean; }
mfa_token?: string;
error?: string; export interface LinkAccountResponse {
error_type?: string; linked_account: LinkedAccount;
} }
class ApiError extends Error { class ApiError extends Error {
@@ -604,80 +598,35 @@ export const api = {
}, },
externalAuth: { externalAuth: {
// Provider management (admin) // List available providers
listProviders: () => listProviders: () =>
request<ExternalProviderListResponse>('/auth/external/providers'), request<ExternalProvidersResponse>('/auth/external/providers'),
getProviderConfig: (provider: ExternalProviderId) => // Get linked accounts for current user
request<ExternalProviderConfig | null>(`/auth/external/providers/${provider}/config`),
updateProviderConfig: (provider: ExternalProviderId, config: Partial<ExternalProviderConfig>) =>
request<void>(`/auth/external/providers/${provider}/config`, {
method: 'POST',
body: JSON.stringify(config),
credentials: 'include',
}),
deleteProviderConfig: (provider: ExternalProviderId) =>
request<void>(`/auth/external/providers/${provider}/config`, {
method: 'DELETE',
credentials: 'include',
}),
// User account management
listLinkedAccounts: () => listLinkedAccounts: () =>
request<LinkedAccountsResponse>('/auth/external/linked-accounts'), request<LinkedAccountsResponse>('/auth/external/linked-accounts'),
unlinkAccount: (provider: ExternalProviderId) => // Initiate OAuth login flow
request<void>(`/auth/external/${provider}/unlink`, { initiateLogin: (provider: string, options?: { redirect_uri?: string; organization_id?: string }) =>
request<OAuthAuthorizeResponse>(`/auth/external/${provider}/authorize`, {
method: 'GET',
credentials: 'include',
}, false),
// Initiate account linking flow (requires auth)
initiateLink: (provider: string, redirect_uri?: string) =>
request<OAuthAuthorizeResponse>(`/auth/external/${provider}/link`, {
method: 'POST',
body: JSON.stringify({ redirect_uri }),
credentials: 'include',
}),
// Unlink an external account
unlinkAccount: (provider: string) =>
request<{ message: string }>(`/auth/external/${provider}/unlink`, {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
}), }),
// OAuth flow initiation
initiateLogin: (provider: ExternalProviderId, state: string) => {
const params = new URLSearchParams({ state });
return request<OAuthAuthorizeResponse>(
`/auth/external/${provider}/authorize?${params.toString()}`,
{
method: 'GET',
credentials: 'include',
},
false
);
},
initiateRegister: (provider: ExternalProviderId, state: string) => {
const params = new URLSearchParams({ state });
return request<OAuthAuthorizeResponse>(
`/auth/external/${provider}/authorize?${params.toString()}`,
{
method: 'GET',
credentials: 'include',
},
false
);
},
initiateLink: (provider: ExternalProviderId, state: string) =>
request<OAuthAuthorizeResponse>(`/auth/external/${provider}/link`, {
method: 'POST',
body: JSON.stringify({ state }),
credentials: 'include',
}),
// OAuth callback (called after redirect from provider)
handleCallback: (provider: ExternalProviderId, code: string, state: string) => {
const params = new URLSearchParams({ code, state });
return request<OAuthCallbackResponse>(
`/auth/external/${provider}/callback?${params.toString()}`,
{
method: 'GET',
credentials: 'include',
},
false
);
},
}, },
}; };
+107 -128
View File
@@ -1,201 +1,180 @@
/** /**
* PKCE (Proof Key for Code Exchange) utilities for OAuth authentication. * PKCE utilities and OAuth state management for external authentication flow.
* Provides secure code_verifier/code_challenge generation and state management. * Supports Google OAuth with PKCE (Proof Key for Code Exchange).
*/ */
import { base64UrlEncode } from './encoding'; /**
* OAuth flow types for state management.
*/
export type OAuthFlow = 'login' | 'register' | 'link';
/** /**
* OAuth flow types supported by the application. * Parameters for storing OAuth state.
*/ */
export type OAuthFlowType = 'login' | 'register' | 'link'; export interface OAuthStateParams {
/**
* OAuth provider types.
*/
export type OAuthProvider = 'google' | 'github' | 'microsoft';
/**
* Interface representing stored OAuth state in sessionStorage.
*/
export interface OAuthState {
/** The state parameter for CSRF protection */
state: string; state: string;
/** The code_verifier for PKCE exchange */ codeVerifier?: string;
codeVerifier: string; flow: OAuthFlow;
/** The type of OAuth flow */ provider: string;
flowType: OAuthFlowType; redirectUri?: string;
/** The OAuth provider */
provider: OAuthProvider;
/** The redirect URI for the callback */
redirectUri: string;
/** Timestamp when the state expires */
expiresAt: number;
} }
/** /**
* Storage key prefix for OAuth state in sessionStorage. * Retrieved OAuth state with metadata.
*/ */
const OAUTH_STATE_PREFIX = 'oauth_state_'; export interface OAuthStateData {
state: string;
codeVerifier?: string;
flow: OAuthFlow;
provider: string;
redirectUri?: string;
timestamp: number;
}
/** /**
* Default expiry time for OAuth state in milliseconds (10 minutes). * State expiration time in milliseconds (10 minutes).
*/ */
const DEFAULT_OAUTH_STATE_EXPIRY = 10 * 60 * 1000; const STATE_EXPIRATION_MS = 10 * 60 * 1000;
/** /**
* Generates a cryptographically secure code_verifier. * Generate a cryptographically secure code verifier for PKCE.
* Per RFC 7636, the code_verifier should be 43-128 characters * The code verifier is a high-entropy cryptographic random string.
* consisting of [A-Z], [a-z], [0-9], "-", ".", "_", "~".
* *
* @returns A random URL-safe code_verifier string * @returns A URL-safe base64-encoded string (43-128 characters)
*/ */
export function generateCodeVerifier(): string { export function generateCodeVerifier(): string {
// Generate 32 random bytes (256 bits) for the verifier // Generate 32 bytes of random data
const array = new Uint8Array(32); const randomBytes = new Uint8Array(32);
crypto.getRandomValues(array); crypto.getRandomValues(randomBytes);
// Encode as base64url without padding return base64UrlEncode(randomBytes);
return base64UrlEncode(array);
} }
/** /**
* Generates a cryptographically secure state parameter for CSRF protection. * Compute the S256 code challenge from a code verifier.
* Uses SHA-256 hash followed by URL-safe base64 encoding.
* *
* @returns A random URL-safe state string * @param verifier - The PKCE code verifier
*/ * @returns The S256 code challenge
export function generateState(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
/**
* Computes the S256 code_challenge from a code_verifier.
* Uses SHA-256 hash followed by base64url encoding without padding.
*
* @param verifier - The code_verifier to compute the challenge from
* @returns The S256 code_challenge as a base64url-encoded string
*/ */
export async function computeCodeChallenge(verifier: string): Promise<string> { export async function computeCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder(); // Convert base64url string back to bytes
const data = encoder.encode(verifier); const verifierBytes = base64UrlDecode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash)); // Compute SHA-256 hash
const hashBuffer = await crypto.subtle.digest('SHA-256', verifierBytes);
const hashBytes = new Uint8Array(hashBuffer);
// Encode as base64url without padding
return base64UrlEncode(hashBytes);
} }
/** /**
* Stores OAuth state in sessionStorage with an expiry time. * Generate a secure state parameter for CSRF protection.
* *
* @param stateData - Object containing OAuth state parameters * @returns A URL-safe base64-encoded string (16 bytes)
*/ */
export function storeOAuthState(stateData: { export function generateState(): string {
state: string; const randomBytes = new Uint8Array(16);
codeVerifier: string; crypto.getRandomValues(randomBytes);
flow: OAuthFlowType;
provider: OAuthProvider;
redirectUri: string;
}): void {
const expiresAt = Date.now() + DEFAULT_OAUTH_STATE_EXPIRY;
const oauthState: OAuthState = { return base64UrlEncode(randomBytes);
state: stateData.state, }
codeVerifier: stateData.codeVerifier,
flowType: stateData.flow, /**
provider: stateData.provider, * Store OAuth state in sessionStorage for validation on callback.
redirectUri: stateData.redirectUri, *
expiresAt, * @param params - OAuth state parameters including state, code verifier, flow, and provider
*/
export function storeOAuthState(params: OAuthStateParams): void {
const storageKey = `oauth_state_${params.state}`;
const stateData: OAuthStateData = {
...params,
timestamp: Date.now(),
}; };
const storageKey = `${OAUTH_STATE_PREFIX}${stateData.state}`; sessionStorage.setItem(storageKey, JSON.stringify(stateData));
sessionStorage.setItem(storageKey, JSON.stringify(oauthState));
} }
/** /**
* Retrieves OAuth state from sessionStorage if it exists and hasn't expired. * Retrieve and validate OAuth state from sessionStorage.
* Returns null if state is not found or has expired.
* *
* @param state - The state parameter to look up * @param state - The state parameter from the OAuth callback
* @returns The OAuthState if found and valid, null otherwise * @returns The stored OAuth state data or null if invalid/expired
*/ */
export function getOAuthState(state: string): OAuthState | null { export function getOAuthState(state: string): OAuthStateData | null {
const storageKey = `${OAUTH_STATE_PREFIX}${state}`; const storageKey = `oauth_state_${state}`;
const stored = sessionStorage.getItem(storageKey); const storedData = sessionStorage.getItem(storageKey);
if (!stored) { if (!storedData) {
return null; return null;
} }
try { try {
const oauthState: OAuthState = JSON.parse(stored); const stateData: OAuthStateData = JSON.parse(storedData);
// Check if the state has expired // Check expiration
if (Date.now() > oauthState.expiresAt) { const now = Date.now();
// Clean up expired state const age = now - stateData.timestamp;
if (age > STATE_EXPIRATION_MS) {
// State has expired, clean up
clearOAuthState(state); clearOAuthState(state);
return null; return null;
} }
return oauthState; return stateData;
} catch { } catch {
// Invalid JSON, clean up and return null // Invalid JSON, clean up
clearOAuthState(state); clearOAuthState(state);
return null; return null;
} }
} }
/** /**
* Clears OAuth state from sessionStorage. * Clear OAuth state from sessionStorage.
* *
* @param state - The state parameter to clear * @param state - The state parameter to clear
*/ */
export function clearOAuthState(state: string): void { export function clearOAuthState(state: string): void {
const storageKey = `${OAUTH_STATE_PREFIX}${state}`; const storageKey = `oauth_state_${state}`;
sessionStorage.removeItem(storageKey); sessionStorage.removeItem(storageKey);
} }
/** /**
* Clears all expired OAuth states from sessionStorage. * Encode bytes to URL-safe base64 without padding.
* Useful for cleanup operations. *
* @param bytes - The bytes to encode
* @returns URL-safe base64 encoded string
*/ */
export function cleanupExpiredOAuthStates(): void { function base64UrlEncode(bytes: Uint8Array): string {
for (let i = 0; i < sessionStorage.length; i++) { const base64 = btoa(String.fromCharCode(...bytes));
const key = sessionStorage.key(i); return base64
.replace(/\+/g, '-')
if (key && key.startsWith(OAUTH_STATE_PREFIX)) { .replace(/\//g, '_')
try { .replace(/=+$/, '');
const stored = sessionStorage.getItem(key);
if (stored) {
const oauthState: OAuthState = JSON.parse(stored);
if (Date.now() > oauthState.expiresAt) {
sessionStorage.removeItem(key);
}
}
} catch {
// Invalid entry, remove it
sessionStorage.removeItem(key);
}
}
}
} }
/** /**
* Validates that a code_verifier meets PKCE requirements. * Decode URL-safe base64 string to bytes.
* Per RFC 7636, the code_verifier must be 43-128 characters
* and match the character set [A-Z], [a-z], [0-9], "-", ".", "_", "~".
* *
* @param verifier - The code_verifier to validate * @param str - The URL-safe base64 string
* @returns true if valid, false otherwise * @returns The decoded bytes
*/ */
export function isValidCodeVerifier(verifier: string): boolean { function base64UrlDecode(str: string): Uint8Array {
// RFC 7636 defines the character set for code_verifier // Add padding if necessary
const validPattern = /^[A-Za-z0-9\-._~]+$/; let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const padding = base64.length % 4;
// Check length requirements (43-128 characters) if (padding) {
if (verifier.length < 43 || verifier.length > 128) { base64 += '='.repeat(4 - padding);
return false;
} }
// Check character set const binaryString = atob(base64);
return validPattern.test(verifier); const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
} }
-4
View File
@@ -378,10 +378,6 @@ export default function LoginPage() {
const authUrl = new URL(response.authorization_url); const authUrl = new URL(response.authorization_url);
authUrl.searchParams.set('state', response.state || state); authUrl.searchParams.set('state', response.state || state);
// Add PKCE parameters
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString(); window.location.href = authUrl.toString();
} catch (error) { } catch (error) {