|
| 1 | +import 'dart:js_interop'; |
| 2 | + |
| 3 | +import 'openid_client.dart'; |
| 4 | +import 'package:web/web.dart' hide Credential, Client; |
| 5 | +import 'dart:async'; |
| 6 | +export 'openid_client.dart'; |
| 7 | + |
| 8 | +/// A wrapper around [Flow] that handles the browser-specific parts of |
| 9 | +/// authentication. |
| 10 | +/// |
| 11 | +/// The constructor takes a [Client] and a list of scopes. It then |
| 12 | +/// creates a [Flow] and uses it to generate an authentication URI. |
| 13 | +/// |
| 14 | +/// The [authorize] method redirects the browser to the authentication URI. |
| 15 | +/// |
| 16 | +/// The [logout] method redirects the browser to the logout URI. |
| 17 | +/// |
| 18 | +/// The [credential] property returns a [Future] that completes with a |
| 19 | +/// [Credential] after the user has signed in and the browser is redirected to |
| 20 | +/// the app. Otherwise, it completes with `null`. |
| 21 | +/// |
| 22 | +/// The state is not persisted in the browser, so the user will have to sign in |
| 23 | +/// again after a page refresh. If you want to persist the state, you'll have to |
| 24 | +/// store and restore the credential yourself. You can listen to the |
| 25 | +/// [Credential.onTokenChanged] event to be notified when the credential changes. |
| 26 | +class Authenticator { |
| 27 | + /// The [Flow] used for authentication. |
| 28 | + /// |
| 29 | + /// This will be a flow of type [FlowType.implicit]. |
| 30 | + final Flow flow; |
| 31 | + |
| 32 | + /// A [Future] that completes with a [Credential] after the user has signed in |
| 33 | + /// and the browser is redirected to the app. Otherwise, it completes with |
| 34 | + /// `null`. |
| 35 | + final Future<Credential?> credential; |
| 36 | + |
| 37 | + Authenticator._(this.flow) : credential = _credentialFromUri(flow); |
| 38 | + |
| 39 | + // Authenticator(Client client, |
| 40 | + // {Iterable<String> scopes = const [], String? device, String? prompt}) |
| 41 | + // : this._(Flow.implicit(client, |
| 42 | + // device: device, |
| 43 | + // state: window.localStorage.getItem('openid_client:state'), |
| 44 | + // prompt: prompt) |
| 45 | + // ..scopes.addAll(scopes) |
| 46 | + // ..redirectUri = Uri.parse(window.location.href).removeFragment()); |
| 47 | + |
| 48 | + // With PKCE flow |
| 49 | + Authenticator( |
| 50 | + Client client, { |
| 51 | + Iterable<String> scopes = const [], |
| 52 | + popToken = '', |
| 53 | + }) : this._( |
| 54 | + Flow.authorizationCodeWithPKCE( |
| 55 | + client, |
| 56 | + state: window.localStorage.getItem('openid_client:state'), |
| 57 | + ) |
| 58 | + ..scopes.addAll(scopes) |
| 59 | + ..redirectUri = Uri.parse( |
| 60 | + window.location.href.contains('#/') |
| 61 | + ? window.location.href.replaceAll('#/', 'callback.html') |
| 62 | + : '${window.location.href}callback.html', |
| 63 | + ).removeFragment() |
| 64 | + ..dPoPToken = popToken, |
| 65 | + ); |
| 66 | + |
| 67 | + /// Redirects the browser to the authentication URI. |
| 68 | + void authorize() { |
| 69 | + _forgetCredentials(); |
| 70 | + window.localStorage.setItem('openid_client:state', flow.state); |
| 71 | + window.location.href = flow.authenticationUri.toString(); |
| 72 | + } |
| 73 | + |
| 74 | + /// Redirects the browser to the logout URI. |
| 75 | + void logout() async { |
| 76 | + _forgetCredentials(); |
| 77 | + var c = await credential; |
| 78 | + if (c == null) return; |
| 79 | + var uri = c.generateLogoutUrl( |
| 80 | + redirectUri: Uri.parse(window.location.href).removeFragment()); |
| 81 | + if (uri != null) { |
| 82 | + window.location.href = uri.toString(); |
| 83 | + } |
| 84 | + } |
| 85 | + |
| 86 | + void _forgetCredentials() { |
| 87 | + window.localStorage.removeItem('openid_client:state'); |
| 88 | + window.localStorage.removeItem('openid_client:auth'); |
| 89 | + } |
| 90 | + |
| 91 | + static Future<Credential?> _credentialFromUri(Flow flow) async { |
| 92 | + var uri = Uri.parse(window.location.href); |
| 93 | + var iframe = uri.queryParameters['iframe'] != null; |
| 94 | + uri = Uri(query: uri.fragment); |
| 95 | + var q = uri.queryParameters; |
| 96 | + if (q.containsKey('access_token') || |
| 97 | + q.containsKey('code') || |
| 98 | + q.containsKey('id_token')) { |
| 99 | + window.history.replaceState(''.toJS, '', |
| 100 | + Uri.parse(window.location.href).removeFragment().toString()); |
| 101 | + window.localStorage.removeItem('openid_client:state'); |
| 102 | + |
| 103 | + var c = await flow.callback(q.cast()); |
| 104 | + if (iframe) window.parent!.postMessage(c.response?.toJSBox, '*'.toJS); |
| 105 | + return c; |
| 106 | + } |
| 107 | + return null; |
| 108 | + } |
| 109 | + |
| 110 | + /// Tries to refresh the access token silently in a hidden iframe. |
| 111 | + /// |
| 112 | + /// The implicit flow does not support refresh tokens. This method uses a |
| 113 | + /// hidden iframe to try to get a new access token without the user having to |
| 114 | + /// sign in again. It returns a [Future] that completes with a [Credential] |
| 115 | + /// when the iframe receives a response from the authorization server. The |
| 116 | + /// future will timeout after [timeout] if the iframe does not receive a |
| 117 | + /// response. |
| 118 | + Future<Credential> trySilentRefresh( |
| 119 | + {Duration timeout = const Duration(seconds: 20)}) async { |
| 120 | + var iframe = HTMLIFrameElement(); |
| 121 | + var url = flow.authenticationUri; |
| 122 | + window.localStorage.setItem('openid_client:state', flow.state); |
| 123 | + iframe.src = url.replace(queryParameters: { |
| 124 | + ...url.queryParameters, |
| 125 | + 'prompt': 'none', |
| 126 | + 'redirect_uri': flow.redirectUri.replace(queryParameters: { |
| 127 | + ...flow.redirectUri.queryParameters, |
| 128 | + 'iframe': 'true', |
| 129 | + }).toString(), |
| 130 | + }).toString(); |
| 131 | + iframe.style.display = 'none'; |
| 132 | + document.body!.append(iframe); |
| 133 | + var event = await window.onMessage.first.timeout(timeout).whenComplete(() { |
| 134 | + iframe.remove(); |
| 135 | + }); |
| 136 | + |
| 137 | + var data = event.data?.dartify(); |
| 138 | + if (data is Map) { |
| 139 | + var current = await credential; |
| 140 | + if (current == null) { |
| 141 | + return flow.client.createCredential( |
| 142 | + accessToken: data['access_token'], |
| 143 | + expiresAt: data['expires_at'] == null |
| 144 | + ? null |
| 145 | + : DateTime.fromMillisecondsSinceEpoch( |
| 146 | + int.parse(data['expires_at'].toString()) * 1000), |
| 147 | + refreshToken: data['refresh_token'], |
| 148 | + expiresIn: data['expires_in'] == null |
| 149 | + ? null |
| 150 | + : Duration(seconds: int.parse(data['expires_in'].toString())), |
| 151 | + tokenType: data['token_type'], |
| 152 | + idToken: data['id_token'], |
| 153 | + ); |
| 154 | + } else { |
| 155 | + return current..updateToken(data.cast()); |
| 156 | + } |
| 157 | + } else { |
| 158 | + throw Exception('$data'); |
| 159 | + } |
| 160 | + } |
| 161 | +} |
0 commit comments