spring_boot_starter_flutter_security 0.6.1
spring_boot_starter_flutter_security: ^0.6.1 copied to clipboard
Security for Spring Boot Flutter apps: the OAuth2 / OpenID Connect app login, secrets in the Keychain / Keystore, widgets that follow the logged-in user.
0.6.1 #
- No changes of its own; released with the family version 0.6.1.
0.6.0 #
- Data starter (#17)
0.5.0 #
Breaking #
- Linux: while the keyring is locked, a call that needs a value throws a
PlatformSecretStoreException(libsecret_locked) instead of reading as "no value" or doing nothing:readof a stored key andwritewhen the unlock prompt is dismissed,deleteanddeleteAllwhen there is a value to delete (a logout kept the tokens). - Windows and Linux: the app's values are kept under an application id,
spring.flutter.security.application-idwhen set, else the app's own (<CompanyName>\<ProductName>ofRunner.rcon Windows, the GTKAPPLICATION_IDon Linux), else the executable's name (a blank id counts as unset); on Windows in%LOCALAPPDATA%\spring_secret_store\<app id>. The values 0.4.x kept under the executable's name (on Windows in%LOCALAPPDATA%\<executable>\spring_secret_store) are moved there once, at the first call after the update, and the old place is never searched again, so another app of the same executable name no longer mixes its values in. - iOS: a reinstalled app starts logged out. Keychain items outlive the app, so at the first start after a reinstall (found by an install id kept in a file of the app's container) the secret store's values and the DPoP key are removed; an update or cleared preferences keep them.
Changes #
- The plugin declares
dartFileNamefor its desktop class (APK, iOS and macOS builds of an app using the plugin failed). - Web server mode with the server on another origin:
ServerCredentialsHttpTransportsends the cookies only with the requests to the server's origin, so a public API answeringAccess-Control-Allow-Origin: *keeps working and never sees them; an app with its own transport builds one withServerCredentialsHttpTransport.forPage(page: ..., server: ...). - Android: a login whose window is destroyed (a
singleTaskmain activity, aCLEAR_TOPintent) ends as cancelled instead of staying busy until the app restarts; a hot restart cancels a login the old code left running, and a window's answer reaches only its own login. - Android's redirect activity and the Windows and Linux loopback server end the login only for the redirect carrying its
state: another site sending the browser to the redirect URI no longer ends a running login (the loopback answers 400 and keeps waiting). - Android's redirect matcher no longer takes an opaque redirect URI (
books.neon.app:callback, which no link can match) or an authority that is no valid host as a match. restore()brings the user back with the authorities of their login, those an application's ownOAuth2UserServiceadded included (kept in the login record); theSCOPE_ones follow the current access token.- A login record of a registration removed from the configuration, or one that cannot be read, is removed with its tokens.
logout()clears the context and publishes theLogoutSuccessEventeven when the store cannot delete the stored login, then throws the store's error.- The stored tokens of two registrations can no longer share a key (
a.b+canda+b.c); tokens stored by 0.4.x are read and moved to the new key. - The loopback window keeps a redirect URI's
localhost(or[::1]) for providers that want it exact, listening on 127.0.0.1 and ::1 with the same port. - spa mode: every login, the silent one too, makes a new WebCrypto DPoP key, and a key read back from IndexedDB is used only while it still is a non-extractable P-256 signing key.
- Windows: keys longer than 64 bytes are stored under their SHA-256, each file is written under a unique temporary name, and the 0.4.x move never fails a call: a file in use or read-only is tried again at later starts, and a value deleted meanwhile never comes back.
SecretTemplategives its errors in the returnedFuture(it threw some synchronously).- Every bean decides "a browser" the same way (the
webprofile), so no platform bean exists next to the web's. - The iOS and macOS privacy manifest states that the plugin uses no "required reason" API (the install id is a file, not
UserDefaults), and a test fails when the Swift code starts using one. - The Android build uses Kotlin 2.4.21 and fixed versions of BouncyCastle, Guava, jose4j, jdom2 and commons-lang3 for the Gradle plugins (advisories on the build machine only; nothing of them is in the APK).
- The docs say what each platform's store protects from: DPAPI and the Secret Service separate users, not apps; Keychain items survive the app's deletion and are in encrypted backups; a software Keychain DPoP key is in the app's memory while it signs.
0.4.3 #
- The Windows (DPAPI) and Linux (libsecret) secret stores are tested against the real systems (
test/desktop_secret_store_test.dart, CI's "Secret stores on Windows and Linux" jobs): reading, writing,keys(),deleteAll(), apps kept apart, the DPoP key kept across restarts. - The web login, spa mode (
spring.flutter.security.web.mode=spa: the browser logs in by itself, for apps without a server of their own;serverstays the default).SpaOAuth2LoginService:loginbuilds the registration's authorization request (PKCES256,state,nonce;dpop_jktwith DPoP), keeps it insessionStoragefor the round trip only and navigates the page to the provider;restorefinishes the login when the page comes back withcodeandstate(thestate, RFC 9207issanderrorchecks of the native login, the code exchanged as a public client, the ID token andnoncechecked, the user loaded), removes the answer from the address bar (history.replaceState) and the request fromsessionStorage; an answer the tab did not ask for, or older than 10 minutes, logs nobody in; after a reload or in a new tab a top-levelprompt=nonetrip to the provider brings back a user who logged in in this browser (a hint inlocalStorage, the registration id only;spring.flutter.security.web.silent-restore,trueby default),login_requiredmeaning nobody;logoutforgets the tokens, revokes them (best effort) and ends the provider's session at itsend_session_endpoint. Tokens in the tab's memory only (TabOAuth2AuthorizedClientService, which warns when a refresh did not rotate the refresh token).WebCryptoDPoPKey, the app'sDPoPKeyin spa mode (webCryptoDPoPKey): an ECDSA P-256 WebCrypto key made withextractable: false, itsCryptoKeyhandles kept in IndexedDB, made at the first proof (LoadableDPoPKey);BrowserKeyVaultandBrowserKeyPairput WebCrypto behind interfaces.SpaBrowserPage(the base address,history.replaceState,sessionStorageandlocalStorageasBrowserStorage;InMemoryBrowserStorage), all throughdart:js_interop, no package. A registration with a client secret is refused (a browser cannot keep it); an unreachable token endpoint names CORS. Beans:spaBrowserPage,tabAuthorizedClientService,webCryptoDPoPKey,spaOAuth2LoginService; the start fails in spa mode without a registration.browserPageis a server-mode bean now. - DPoP keys that survive a restart (RFC 9449):
PlatformDPoPKey, the app'sDPoPKeybean (platformDPoPKey, wherever a registration is configured, not on the web), ordered before the OAuth2 client's key of one run, which backs off. An EC P-256 key in the Secure Enclave on iOS and macOS (a Keychain key where there is no usable Secure Enclave), in the Android Keystore (StrongBox when there is one), signing in the platform (DER turned intoR || S); on Windows and LinuxSecretStoreDPoPKey, a key made in Dart and kept as a private JWK in theSecretStore(oauth2.dpop-key). Made at the first proof; a key gone (PlatformDPoPKeyException,no_key) is replaced at the next.DPoPKeyStoragesays where the key is. The channelspring_boot_starter_flutter_security/dpop_key(dpopPublicJwk,dpopSign,dpopDelete). SecretStoreOAuth2AuthorizedClientServicekeeps DPoP tokens (it readBeareronly): the token type, and with its newdPoPKeythe thumbprint of the key a DPoP token is bound to; tokens bound to another key (lost, deleted, replaced) are dropped when loaded, so the user logs in again at once. Records without a type read asBearer.NativeOAuth2LoginService.logout()deletes thePlatformDPoPKey(newdPoPKey), so the next login gets a new key;restore()treatsinvalid_dpop_proofat the refresh as a refused login.- The web login, server mode (backend-for-frontend,
spring.flutter.security.web.mode=server, the default):OAuth2LoginServicekeeps its calls in a browser, where the app's server logs the user in withoauth2Login()and the page holds only anHttpOnlysession cookie.ServerOAuth2LoginService:loginnavigates the page to the server's/oauth2/authorization/{registrationId}(and returnsnull),restoreasks the server's user endpoint ({"name", "authorities", "registrationId", "attributes"}) and puts anOAuth2AuthenticationTokenor aPreAuthenticatedAuthenticationTokeninto the context,logoutposts the server's logout with the CSRF token and follows a{"logoutUrl"}answer or aLocationon a2xx(http(s)only).CsrfCookieHttpTransportcopies theXSRF-TOKENcookie intoX-XSRF-TOKENon unsafe requests to the server only; in a browser the package declares it as thehttpTransportbean aroundfetch(withFetchCredentials.includefor a server on another origin), before Feign's, and backs off for an application's ownHttpTransport.BrowserPage(address, cookies, navigation; a bean, faked in tests),FlutterWebSecurityProperties(spring.flutter.security.web.mode,server-url,user-endpoint,logout-url,authorization-base-uri). Outside a browser nothing changes. - Breaking:
OAuth2LoginServiceis an interface (loginreturnsFuture<Authentication?>,restoreFuture<Authentication?>,authenticationanAuthentication?); the login of phones and desktops isNativeOAuth2LoginService(its constructor,user,sessionKeyand the narrowerOAuth2AuthenticationTokentypes are those of the former class). The module now comes before Feign's too. - First version: the login of an app with OAuth 2.0 / OpenID Connect and the platform code of logins and secrets (a Flutter plugin with its own platform code, no community plugins), on the registrations, token endpoint clients and authorized clients of
spring_boot_starter_oauth2_client:OAuth2LoginService, the app login:login(authorization code with PKCE in theAuthorizationWindow, thestate,iss, ID token and user info checks),restoreat startup from the stored tokens,logoutwith token revocation (OAuth2TokenRevocationClient, RFC 7009) and RP-initiated logout. In a web page the server logs in (see above).AuthorizationWindowandAuthorizationCancelledException.PlatformAuthorizationWindow, the window of Android, iOS and macOS:ASWebAuthenticationSession(custom schemes;httpsredirects on iOS 17.4 / macOS 14.4;preferEphemeralSession) and Custom Tabs with the redirect caught by the package'sRedirectReceiverActivity, which an app that logs in declares with its scheme in itsAndroidManifest.xml; one login at a time; other failures areAuthorizationWindowExceptions.LoopbackAuthorizationWindow, the window of Windows and Linux: the default browser and a one-shot server on127.0.0.1(RFC 8252 §7.3).SecretStoreandInMemorySecretStore.PlatformSecretStore, the app'sSecretStorebean: the Keychain on iOS and macOS (generic passwords, this device only, not synced; the login keychain for macOS builds without a team), an AES-256-GCM key of the Android Keystore with the values in a private preferences file, DPAPI files on Windows and the Secret Service through libsecret on Linux (both throughdart:ffi, nothing native to build). None on the web (anInMemorySecretStorethat the login and the template never fill). Errors arePlatformSecretStoreExceptions.SecretStoreOAuth2AuthorizedClientService, the app'sOAuth2AuthorizedClientService: the tokens as JSON in theSecretStore, so the login survives a restart.SecretTemplate, the app's own secrets as JSON objects under a namespace; it throws on the web.- The beans are registered unless the application declares its own, before the OAuth2 client's auto-configuration, so the authorized client service replaces the client's in-memory one.
- Depends on and re-exports
spring_boot_starter_flutter,spring_boot_starter_oauth2_clientandspring_security_core(the Windows and Linux stores usedart:ffialone, nopackage:ffi).