google login works
This commit is contained in:
+55
-106
@@ -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
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+108
-129
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,10 +377,6 @@ export default function LoginPage() {
|
|||||||
// Redirect to provider authorization page
|
// Redirect to provider authorization page
|
||||||
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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user