feat: add OAuth2/OIDC with PKCE for SSO login
Add opt-in SSO authentication alongside Basic Auth. OAuth endpoints are auto-discovered via .well-known, with support for external IdPs (Keycloak, Authentik) via configurable OAUTH_ISSUER_URL. Sessions persist through httpOnly refresh token cookies with automatic renewal.
This commit is contained in:
+4
-3
@@ -161,14 +161,15 @@ export class JMAPClient {
|
||||
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
|
||||
|
||||
try {
|
||||
const sessionResponse = await fetch(sessionUrl, {
|
||||
const sessionResponse = await this.authenticatedFetch(sessionUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': this.authHeader },
|
||||
});
|
||||
|
||||
if (!sessionResponse.ok) {
|
||||
if (sessionResponse.status === 401) {
|
||||
throw new Error('Invalid username or password');
|
||||
throw new Error(this.authMode === 'bearer'
|
||||
? 'Authentication failed - token may be expired'
|
||||
: 'Invalid username or password');
|
||||
}
|
||||
throw new Error(`Failed to get session: ${sessionResponse.status}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface OAuthMetadata {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
revocation_endpoint?: string;
|
||||
end_session_endpoint?: string;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
|
||||
|
||||
export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> {
|
||||
const cached = metadataCache.get(serverUrl);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
|
||||
if (cached) metadataCache.delete(serverUrl);
|
||||
|
||||
const urls = [
|
||||
`${serverUrl}/.well-known/oauth-authorization-server`,
|
||||
`${serverUrl}/.well-known/openid-configuration`,
|
||||
];
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
errors.push(`${url} returned HTTP ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.authorization_endpoint && data.token_endpoint) {
|
||||
const metadata: OAuthMetadata = {
|
||||
issuer: data.issuer,
|
||||
authorization_endpoint: data.authorization_endpoint,
|
||||
token_endpoint: data.token_endpoint,
|
||||
revocation_endpoint: data.revocation_endpoint,
|
||||
end_session_endpoint: data.end_session_endpoint,
|
||||
};
|
||||
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
return metadata;
|
||||
}
|
||||
errors.push(`${url} response missing required endpoints`);
|
||||
} catch (err) {
|
||||
errors.push(`${url}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[OAuth] Discovery failed for ${serverUrl}: ${errors.join('; ')}`);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
function base64urlEncode(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64urlEncode(array.buffer);
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64urlEncode(digest);
|
||||
}
|
||||
|
||||
export function generateState(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64urlEncode(array.buffer);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const OAUTH_SCOPES = 'openid email profile';
|
||||
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
|
||||
Reference in New Issue
Block a user