Files
tatlock-ui/lib/core/auth/oidc_service_web.dart
T
Jeroen SchweitzerandClaude Opus 4.5 9c41a8805d
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
chore: release v1.0.5
Web authentication now uses OIDC Authorization Code flow with PKCE
instead of NPM forward auth. Added callback route and web utilities.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:01:42 +01:00

197 lines
6.3 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import '../config/app_config.dart';
import 'oidc_service.dart';
/// Web implementation of OIDC service using browser redirect flow.
///
/// Uses Authorization Code flow with PKCE for secure authentication.
/// On web, we can't use flutter_appauth, so we implement the flow manually
/// using browser redirects and URL parsing.
class OidcServiceWeb implements OidcService {
OidcServiceWeb({Dio? dio}) : _dio = dio ?? Dio();
final Dio _dio;
/// OIDC scopes to request.
static const _scopes = ['openid', 'profile', 'email', 'offline_access'];
/// Redirect URI for web.
static String get _redirectUri => '${AppConfig.webBaseUrl}/callback';
// PKCE state stored during auth flow (in-memory for single-page app)
static String? _codeVerifier;
static String? _state;
/// Get the authorization URL to redirect the browser to.
///
/// Returns a URL that the browser should navigate to for authentication.
/// The [codeVerifier] and [state] are stored for later verification.
Future<String> getAuthorizationUrl() async {
// Fetch OIDC discovery document
final discovery = await _fetchDiscovery();
final authEndpoint = discovery['authorization_endpoint'] as String;
// Generate PKCE code verifier and challenge
_codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(_codeVerifier!);
// Generate state for CSRF protection
_state = _generateRandomString(32);
// Build authorization URL
final params = {
'client_id': AppConfig.authClientId,
'redirect_uri': _redirectUri,
'response_type': 'code',
'scope': _scopes.join(' '),
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'state': _state,
};
final uri = Uri.parse(authEndpoint).replace(queryParameters: params);
developer.log('Authorization URL: $uri', name: 'oidc_web');
return uri.toString();
}
/// Exchange authorization code for tokens.
///
/// Call this after the browser redirects back with the authorization code.
/// [code] is the authorization code from the callback URL.
/// [state] is the state parameter from the callback URL (verified for CSRF).
Future<OidcTokens> exchangeCode(String code, String state) async {
// Verify state matches
if (_state == null || state != _state) {
throw OidcException('State mismatch - possible CSRF attack');
}
if (_codeVerifier == null) {
throw OidcException('No code verifier - flow not started properly');
}
try {
// Fetch token endpoint from discovery
final discovery = await _fetchDiscovery();
final tokenEndpoint = discovery['token_endpoint'] as String;
developer.log('Exchanging code at: $tokenEndpoint', name: 'oidc_web');
// Exchange code for tokens
final response = await _dio.post<Map<String, dynamic>>(
tokenEndpoint,
data: {
'grant_type': 'authorization_code',
'client_id': AppConfig.authClientId,
'redirect_uri': _redirectUri,
'code': code,
'code_verifier': _codeVerifier,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
final data = response.data!;
developer.log('Token exchange successful', name: 'oidc_web');
// Clear stored PKCE state
_codeVerifier = null;
_state = null;
return OidcTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String?,
expiresAt: DateTime.now().add(
Duration(seconds: data['expires_in'] as int? ?? 3600),
),
idToken: data['id_token'] as String?,
);
} on DioException catch (e) {
developer.log('Token exchange failed: $e', name: 'oidc_web');
throw OidcException('Token exchange failed: ${e.message}');
} finally {
// Clear PKCE state on error too
_codeVerifier = null;
_state = null;
}
}
/// Not used on web - use [getAuthorizationUrl] and [exchangeCode] instead.
@override
Future<OidcTokens> signIn() async {
throw OidcException(
'signIn() not supported on web. Use getAuthorizationUrl() and exchangeCode() instead.',
);
}
/// Refresh the access token using a refresh token.
@override
Future<OidcTokens> refreshToken(String refreshToken) async {
try {
final discovery = await _fetchDiscovery();
final tokenEndpoint = discovery['token_endpoint'] as String;
final response = await _dio.post<Map<String, dynamic>>(
tokenEndpoint,
data: {
'grant_type': 'refresh_token',
'client_id': AppConfig.authClientId,
'refresh_token': refreshToken,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
final data = response.data!;
return OidcTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String? ?? refreshToken,
expiresAt: DateTime.now().add(
Duration(seconds: data['expires_in'] as int? ?? 3600),
),
idToken: data['id_token'] as String?,
);
} on DioException catch (e) {
throw OidcException('Token refresh failed: ${e.message}');
}
}
/// Fetch OIDC discovery document.
Future<Map<String, dynamic>> _fetchDiscovery() async {
final response = await _dio.get<Map<String, dynamic>>(
AppConfig.authDiscoveryUrl,
);
return response.data!;
}
/// Generate a random code verifier for PKCE.
String _generateCodeVerifier() {
return _generateRandomString(64);
}
/// Generate code challenge from verifier using S256.
String _generateCodeChallenge(String verifier) {
final bytes = utf8.encode(verifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Generate a random string of given length.
String _generateRandomString(int length) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final random = Random.secure();
return List.generate(length, (_) => chars[random.nextInt(chars.length)])
.join();
}
}