From d2f6162aa14b4edbfc646a031f4ae14bddaaae90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Brandtz=C3=A6g?= Date: Sat, 2 Aug 2025 22:04:33 +0200 Subject: [PATCH] feat: web support Add web support with PKCE auth. Only supports redirect! No popup (yet). --- flutter_appauth/example/lib/main.dart | 54 ++- flutter_appauth/example/web/index.html | 19 + flutter_appauth/example/web/manifest.json | 11 + .../lib/src/flutter_appauth_web.dart | 387 ++++++++++++++++++ flutter_appauth/pubspec.yaml | 8 + 5 files changed, 471 insertions(+), 8 deletions(-) create mode 100644 flutter_appauth/example/web/index.html create mode 100644 flutter_appauth/example/web/manifest.json create mode 100644 flutter_appauth/lib/src/flutter_appauth_web.dart diff --git a/flutter_appauth/example/lib/main.dart b/flutter_appauth/example/lib/main.dart index a47b0893..b19b74b8 100644 --- a/flutter_appauth/example/lib/main.dart +++ b/flutter_appauth/example/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io' show Platform; import 'dart:math'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; @@ -28,6 +29,38 @@ class _MyAppState extends State { String? _idToken; String? _error; + @override + void initState() { + super.initState(); + if (kIsWeb) { + _checkForPendingAuthorization(); + } + } + + Future _checkForPendingAuthorization() async { + await Future.delayed(const Duration(milliseconds: 100)); + + setState(() { + _isBusy = true; + }); + + try { + final result = await _appAuth.authorizeAndExchangeCode( + AuthorizationTokenRequest(_clientId, _redirectUrl, + serviceConfiguration: _serviceConfiguration, + scopes: _scopes), + ); + _processAuthTokenResponse(result); + await _testApi(result); + } catch (e) { + if (!e.toString().contains('authorization_in_progress')) { + _handleError(e); + } + } finally { + _clearBusyState(); + } + } + final TextEditingController _authorizationCodeTextController = TextEditingController(); final TextEditingController _accessTokenTextController = @@ -40,13 +73,16 @@ class _MyAppState extends State { TextEditingController(); String? _userInfo; - // For a list of client IDs, go to https://demo.duendesoftware.com final String _clientId = 'interactive.public'; - final String _redirectUrl = 'com.duendesoftware.demo:/oauthredirect'; + final String _redirectUrl = kIsWeb + ? 'http://localhost:8080/' + : 'com.duendesoftware.demo:/oauthredirect'; final String _issuer = 'https://demo.duendesoftware.com'; final String _discoveryUrl = 'https://demo.duendesoftware.com/.well-known/openid-configuration'; - final String _postLogoutRedirectUrl = 'com.duendesoftware.demo:/'; + final String _postLogoutRedirectUrl = kIsWeb + ? 'http://localhost:8080/' + : 'com.duendesoftware.demo:/'; final List _scopes = [ 'openid', 'profile', @@ -96,9 +132,11 @@ class _MyAppState extends State { const SizedBox(height: 8), ElevatedButton( child: const Text('Sign in with auto code exchange'), - onPressed: () => _signInWithAutoCodeExchange(), + onPressed: () { + _signInWithAutoCodeExchange(); + }, ), - if (Platform.isIOS || Platform.isMacOS) + if (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) Padding( padding: const EdgeInsets.all(8.0), child: ElevatedButton( @@ -112,7 +150,7 @@ class _MyAppState extends State { .ephemeralAsWebAuthenticationSession), ), ), - if (Platform.isIOS) + if (!kIsWeb && Platform.isIOS) Padding( padding: const EdgeInsets.all(8.0), child: ElevatedButton( @@ -139,7 +177,7 @@ class _MyAppState extends State { : null, child: const Text('End session'), ), - if (Platform.isIOS || Platform.isMacOS) + if (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) Padding( padding: const EdgeInsets.all(8.0), child: ElevatedButton( @@ -153,7 +191,7 @@ class _MyAppState extends State { child: const Text('End session using ephemeral session'), )), - if (Platform.isIOS) + if (!kIsWeb && Platform.isIOS) Padding( padding: const EdgeInsets.all(8.0), child: ElevatedButton( diff --git a/flutter_appauth/example/web/index.html b/flutter_appauth/example/web/index.html new file mode 100644 index 00000000..1b6fe9e6 --- /dev/null +++ b/flutter_appauth/example/web/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + flutter_appauth_example + + + + + diff --git a/flutter_appauth/example/web/manifest.json b/flutter_appauth/example/web/manifest.json new file mode 100644 index 00000000..8fbf6f5b --- /dev/null +++ b/flutter_appauth/example/web/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "flutter_appauth_example", + "short_name": "flutter_appauth_example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A Flutter AppAuth example application.", + "orientation": "portrait-primary", + "prefer_related_applications": false +} diff --git a/flutter_appauth/lib/src/flutter_appauth_web.dart b/flutter_appauth/lib/src/flutter_appauth_web.dart new file mode 100644 index 00000000..d53d3cab --- /dev/null +++ b/flutter_appauth/lib/src/flutter_appauth_web.dart @@ -0,0 +1,387 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_appauth_platform_interface/flutter_appauth_platform_interface.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:http/http.dart' as http; +import 'package:web/web.dart' as web; + +class FlutterAppAuthWeb extends FlutterAppAuthPlatform { + static String generateCodeChallenge(String codeVerifier) { + final bytes = utf8.encode(codeVerifier); + final digest = sha256.convert(bytes); + return base64Url.encode(digest.bytes).replaceAll('=', ''); + } + + static void registerWith(Registrar registrar) { + _checkForOAuthCallbackOnInit(); + FlutterAppAuthPlatform.instance = FlutterAppAuthWeb._(); + } + + static AuthorizationResponse? _pendingAuthResponse; + static Completer? _pendingTokenCompleter; + static bool _hasCheckedCallback = false; + static Map? _pendingRequest; + + FlutterAppAuthWeb._(); + + FlutterAppAuthWeb() { + _checkForOAuthCallbackOnInit(); + } + + static void _checkForOAuthCallbackOnInit() { + if (_hasCheckedCallback) return; + _hasCheckedCallback = true; + final uri = Uri.parse(web.window.location.href); + final code = uri.queryParameters['code']; + final returnedState = uri.queryParameters['state']; + + if (code != null) { + final storedState = web.window.sessionStorage['appauth_state']; + final codeVerifier = web.window.sessionStorage['appauth_code_verifier']; + final nonce = web.window.sessionStorage['appauth_nonce']; + + if (returnedState == storedState) { + _pendingAuthResponse = AuthorizationResponse( + authorizationCode: code, + codeVerifier: codeVerifier, + nonce: nonce, + ); + + final storedRequest = web.window.sessionStorage['appauth_request']; + if (storedRequest != null) { + try { + _pendingRequest = + json.decode(storedRequest) as Map; + web.window.sessionStorage.removeItem('appauth_request'); + } catch (e) { + // Ignore parsing errors + } + } + } + } + } + + String _generateRandomString(int length) { + const chars = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; + final random = Random.secure(); + return List.generate(length, (_) => chars[random.nextInt(chars.length)]) + .join(); + } + + String _generateCodeChallenge(String codeVerifier) { + return generateCodeChallenge(codeVerifier); + } + + Future _completeTokenExchange( + AuthorizationTokenRequest request, + AuthorizationResponse authResponse,) async { + final tokenResponse = await token( + TokenRequest( + request.clientId, + request.redirectUrl, + issuer: request.issuer, + discoveryUrl: request.discoveryUrl, + serviceConfiguration: request.serviceConfiguration, + scopes: request.scopes, + additionalParameters: request.additionalParameters, + allowInsecureConnections: request.allowInsecureConnections ?? false, + authorizationCode: authResponse.authorizationCode, + codeVerifier: authResponse.codeVerifier, + grantType: GrantType.authorizationCode, + ), + ); + + final finalResponse = AuthorizationTokenResponse( + tokenResponse.accessToken, + tokenResponse.refreshToken, + tokenResponse.accessTokenExpirationDateTime, + tokenResponse.idToken, + tokenResponse.tokenType, + tokenResponse.scopes, + authResponse.authorizationAdditionalParameters, + tokenResponse.tokenAdditionalParameters, + ); + + _pendingAuthResponse = null; + web.window.sessionStorage.removeItem('appauth_state'); + web.window.sessionStorage.removeItem('appauth_code_verifier'); + web.window.sessionStorage.removeItem('appauth_nonce'); + + final currentUri = Uri.parse(web.window.location.href); + final cleanedUri = currentUri.replace(queryParameters: {}); + web.window.history.replaceState(null, '', cleanedUri.toString()); + + return finalResponse; + } + + @override + Future authorize(AuthorizationRequest request) async { + if (_pendingAuthResponse != null) { + final response = _pendingAuthResponse!; + _pendingAuthResponse = null; + return response; + } + + final codeVerifier = _generateRandomString(128); + final codeChallenge = _generateCodeChallenge(codeVerifier); + + final state = _generateRandomString(32); + + web.window.sessionStorage['appauth_code_verifier'] = codeVerifier; + web.window.sessionStorage['appauth_state'] = state; + if (request.nonce != null) { + web.window.sessionStorage['appauth_nonce'] = request.nonce!; + } + + final authorizationEndpoint = + request.serviceConfiguration!.authorizationEndpoint; + final params = { + 'client_id': request.clientId, + 'redirect_uri': request.redirectUrl, + 'response_type': 'code', + 'scope': request.scopes?.join(' ') ?? 'openid profile', + 'state': state, + 'code_challenge': codeChallenge, + 'code_challenge_method': 'S256', + }; + + if (request.nonce != null) { + params['nonce'] = request.nonce!; + } + + if (request.loginHint != null) { + params['login_hint'] = request.loginHint!; + } + + if (request.promptValues != null) { + params['prompt'] = request.promptValues!.join(' '); + } + + if (request.responseMode != null) { + params['response_mode'] = request.responseMode!; + } + + request.additionalParameters?.forEach((key, value) { + params[key] = value; + }); + + final authUri = Uri.parse(authorizationEndpoint).replace( + queryParameters: params, + ); + + web.window.location.href = authUri.toString(); + + return Completer().future; + } + + @override + Future token(TokenRequest request) async { + final tokenEndpoint = request.serviceConfiguration!.tokenEndpoint; + + final body = { + 'client_id': request.clientId, + 'redirect_uri': request.redirectUrl, + 'grant_type': request.grantType!, + }; + + if (request.authorizationCode != null) { + body['code'] = request.authorizationCode!; + } + + if (request.codeVerifier != null) { + body['code_verifier'] = request.codeVerifier!; + } + + if (request.refreshToken != null) { + body['refresh_token'] = request.refreshToken!; + } + + if (request.scopes != null) { + body['scope'] = request.scopes!.join(' '); + } + + request.additionalParameters?.forEach((key, value) { + body[key] = value; + }); + + final bodyString = body.entries + .map((e) => '${e.key}=${Uri.encodeComponent(e.value)}') + .join('&'); + + final response = await http.post( + Uri.parse(tokenEndpoint), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: bodyString, + ); + + if (response.statusCode != 200) { + throw FlutterAppAuthPlatformException( + code: 'token_failed', + message: 'Token request failed with status ${response.statusCode}', + platformErrorDetails: FlutterAppAuthPlatformErrorDetails( + error: 'token_request_failed', + errorDescription: + 'Token request failed with status ${response.statusCode}', + ), + ); + } + + final responseData = json.decode(response.body) as Map; + + return TokenResponse( + responseData['access_token'] as String?, + responseData['refresh_token'] as String?, + responseData['access_token'] != null + ? DateTime.now().add( + Duration(seconds: responseData['expires_in'] as int? ?? 3600), + ) + : null, + responseData['id_token'] as String?, + responseData['token_type'] as String?, + responseData['scope']?.toString().split(' '), + { + for (final entry in responseData.entries) + if (![ + 'access_token', + 'refresh_token', + 'expires_in', + 'id_token', + 'token_type', + 'scope' + ].contains(entry.key)) + entry.key: entry.value.toString() + }, + ); + } + + @override + Future authorizeAndExchangeCode( + AuthorizationTokenRequest request) async { + if (_pendingAuthResponse != null && _pendingRequest != null && + _pendingRequest!['isAuthorizeAndExchangeCode'] == 'true') { + AuthorizationServiceConfiguration? serviceConfig; + if (_pendingRequest!['serviceConfiguration'] != null) { + final configData = _pendingRequest!['serviceConfiguration'] as Map< + String, + dynamic>; + serviceConfig = AuthorizationServiceConfiguration( + authorizationEndpoint: configData['authorizationEndpoint'] as String, + tokenEndpoint: configData['tokenEndpoint'] as String, + endSessionEndpoint: configData['endSessionEndpoint'] as String?, + ); + } + + final storedRequest = AuthorizationTokenRequest( + _pendingRequest!['clientId'] as String, + _pendingRequest!['redirectUrl'] as String, + issuer: _pendingRequest!['issuer'] as String?, + discoveryUrl: _pendingRequest!['discoveryUrl'] as String?, + scopes: (_pendingRequest!['scopes'] as List?)?.cast(), + additionalParameters: (_pendingRequest!['additionalParameters'] as Map< + String, + dynamic>?)?.cast(), + allowInsecureConnections: _pendingRequest!['allowInsecureConnections'] as bool? ?? + false, + serviceConfiguration: serviceConfig ?? request.serviceConfiguration, + ); + _pendingRequest = null; + return _completeTokenExchange(storedRequest, _pendingAuthResponse!); + } + + final requestData = { + 'clientId': request.clientId, + 'redirectUrl': request.redirectUrl, + 'issuer': request.issuer, + 'discoveryUrl': request.discoveryUrl, + 'scopes': request.scopes, + 'additionalParameters': request.additionalParameters, + 'allowInsecureConnections': request.allowInsecureConnections, + 'isAuthorizeAndExchangeCode': 'true', + }; + + if (request.serviceConfiguration != null) { + requestData['serviceConfiguration'] = { + 'authorizationEndpoint': request.serviceConfiguration! + .authorizationEndpoint, + 'tokenEndpoint': request.serviceConfiguration!.tokenEndpoint, + 'endSessionEndpoint': request.serviceConfiguration!.endSessionEndpoint, + }; + } + + web.window.sessionStorage['appauth_request'] = json.encode(requestData); + + await authorize( + AuthorizationRequest( + request.clientId, + request.redirectUrl, + issuer: request.issuer, + discoveryUrl: request.discoveryUrl, + serviceConfiguration: request.serviceConfiguration, + loginHint: request.loginHint, + scopes: request.scopes, + additionalParameters: request.additionalParameters, + promptValues: request.promptValues, + allowInsecureConnections: request.allowInsecureConnections ?? false, + nonce: request.nonce, + responseMode: request.responseMode, + ), + ); + + throw FlutterAppAuthPlatformException( + code: 'authorization_in_progress', + message: 'Authorization is in progress. The page will redirect.', + platformErrorDetails: FlutterAppAuthPlatformErrorDetails( + error: 'redirect_in_progress', + errorDescription: 'The authorization flow requires a page redirect.', + ), + ); + } + + @override + Future endSession(EndSessionRequest request) async { + final endSessionEndpoint = request.serviceConfiguration!.endSessionEndpoint; + + if (endSessionEndpoint == null) { + throw FlutterAppAuthPlatformException( + code: 'end_session_failed', + message: 'End session endpoint not configured', + platformErrorDetails: FlutterAppAuthPlatformErrorDetails( + error: 'missing_end_session_endpoint', + errorDescription: 'End session endpoint not configured', + ), + ); + } + + final params = {}; + + if (request.idTokenHint != null) { + params['id_token_hint'] = request.idTokenHint!; + } + + if (request.postLogoutRedirectUrl != null) { + params['post_logout_redirect_uri'] = request.postLogoutRedirectUrl!; + } + + if (request.state != null) { + params['state'] = request.state!; + } + + request.additionalParameters?.forEach((key, value) { + params[key] = value; + }); + + final uri = Uri.parse(endSessionEndpoint).replace( + queryParameters: params, + ); + + web.window.location.href = uri.toString(); + + return EndSessionResponse(request.state); + } +} diff --git a/flutter_appauth/pubspec.yaml b/flutter_appauth/pubspec.yaml index 3faa1fc0..2da8f5b3 100644 --- a/flutter_appauth/pubspec.yaml +++ b/flutter_appauth/pubspec.yaml @@ -13,6 +13,11 @@ dependencies: flutter: sdk: flutter flutter_appauth_platform_interface: ^9.0.0 + flutter_web_plugins: + sdk: flutter + crypto: ^3.0.0 + web: ^1.0.0 + http: ^1.0.0 flutter: plugin: @@ -24,5 +29,8 @@ flutter: pluginClass: FlutterAppauthPlugin macos: pluginClass: FlutterAppauthPlugin + web: + pluginClass: FlutterAppAuthWeb + fileName: src/flutter_appauth_web.dart dev_dependencies: flutter_lints: ^4.0.0