oidc 5.0.2
oidc: ^5.0.2 copied to clipboard
A comprehensive OpenIdConnect plugin that works on all platforms (android, ios, windows, linux, web, macos)
oidc #
An OpenId Connect RP (Relying Party) plugin for flutter.
Make sure you read the for extra information.
Table Of Contents #
Introduction ✨ #
This federated plugin builds on top of to add platform-specific handling which is required by the spec (e.g. launching a browser, listening for redirect, etc...).
Installation 💻 #
❗ In order to start using this plugin you must have the Flutter SDK installed on your machine.
Add to your pubspec.yaml:
dart pub add oidc oidc_default_store
Usage 🛠️ #
After following the Getting Started steps, it's as easy as:
//1. create the manager:
final manager = OidcUserManager.lazy(
discoveryDocumentUri: OidcUtils.getOpenIdConfigWellKnownUri(
Uri.parse('https://server.example.com'),
),
// TODO: add other settings
);
//2. init()
await manager.init();
//3. listen to user changes
manager.userChanges().listen((user) {
print('currentUser changed to $user');
});
//4. login
final newUser = await manager.loginAuthorizationCodeFlow();
//5. logout
await manager.logout();
Features 📚 #
- 🧩 Cross platform: most features work on all platforms that can run flutter (Android, Ios, macos, web, windows, linux).
- 🧰 High maintenance: everyone hates having to fix an unmaintained package. you can trust that we will solve issues as soon as they pop up. especially since we use this package in all our production apps.
- ⚙️ Customizability: you can customize everything; Where to store the data, provide your own http client, extend requests/responses with your own data; whatever you want, you can do.
- 🚀 Easy to use: you mainly need to concern yourself with the
OidcUserManagerclass, which is very well documented and has a simple interface.
📜 Conformance #
- this package is an OpenID Connect Certified Relying Party, and is additionally tested against multiple conformance profiles.
Implemented specs #
- OpenId Connect Core 1.0.
- authorization code, hybrid (
loginHybridFlow), and implicit flows.
- authorization code, hybrid (
- OpenId Connect Discovery.
- §4 Provider Configuration: the
.well-known/openid-configurationdocument (also RFC 8414.well-known/oauth-authorization-server), withissuervalidation and RFC 8414signed_metadataverification. - §2 OpenID Provider Issuer Discovery: WebFinger via
OidcEndpoints.getIssuerViaWebFinger('joe@example.com'), including §2.1 identifier normalization (OidcUtils.normalizeWebFingerIdentifier), HTTPS-only transport with https-only redirects, and client-siderelfiltering. Resolve the identifier to an issuer first, then pass that issuer toOidcUserManager— the manager does not run WebFinger on your behalf. Internationalized host names are not supported: convert them to their punycode A-label (RFC 5891) before calling, or normalization throws.
- §4 Provider Configuration: the
- RP Initiated logout.
- Front-Channel Logout.
- Authorization code grant with PKCE.
- Resource Owner Password Credentials Grant.
- Automatic Refresh Token rotation.
- OAuth 2.0 For Native Apps
- OAuth 2.0 Device Authorization Grant
- Session Management (Web only).
- Web only, by design — not a gap that will be filled on native platforms. The RP-side check embeds
check_session_iframein a frame the RP controls, posts"$clientId $sessionState"into it, and reads back the OP's answer; that answer is computed from the OP session cookie held by whichever browser is loading the iframe. RFC 8252 §8.12 requires native apps (Android, iOS, macOS, Linux, Windows) to perform login in an external user-agent, not an embedded WebView, so there is no app-controlled browser surface left to host that frame in. Even granting a SEPARATE, app-controlled WebView purely for session polling would not help: OpenID Connect Session Management 1.0 §3.2 ties the answer to the cookie in the user-agent that did the §8.12 login, which a second, freshly-created WebView never has — it could only ever answer "changed", which is not a meaningful check. - On native platforms, detect a session that ended at the OP through other signals instead:
OidcTokenRefreshFailedEvent(a terminal automatic-refresh failure, e.g.invalid_grant, surfaces the next time the token is refreshed),OidcUserInfoFailedEvent(a401from the userinfo endpoint, surfaces the next time it's called) — both onmanager.events()— Front-Channel Logout or Back-Channel Logout if your OP can push the logout to the app, or OpenID Connect Native SSO for Mobile Apps if multiple apps from the same vendor need to share a signed-out state. None of these are instant likecheck_session_iframepolling — they fire on the next token use, not the moment the OP session ends.
- Web only, by design — not a gap that will be filled on native platforms. The RP-side check embeds
- Dynamic Client Registration (RFC 7591).
- Set
OidcUserManagerSettings.dynamicClientRegistrationandinit()registers the client at the discovery document'sregistration_endpoint(optionally with an initial access token), then runs as the ISSUED credentials — theclientCredentialsyou pass to the constructor become a pre-registration seed. The issuedtoken_endpoint_auth_methodis what every back-channel call then uses, and it cannot be overridden — OpenID Connect Core §3.1.3.1 and §12.1 both require a client to "authenticate to the Token Endpoint using the authentication method registered for itsclient_id".preferredTokenEndpointAuthMethoddecides only when the OP states none, where RFC 7591 §2 would otherwise pickclient_secret_basicunaided. - The manager registers once per issuer and then leaves it alone. One immutable record is persisted under one key; every later launch restores it with zero network calls. That record is re-issued only when the request this build would send no longer matches the one it was issued for, or when its
client_secrethas expired. Nothing else in the library ever re-derives, rotates or replaces the client identity on its own — the only other way it moves is an RFC 7592 call you make through the manager (below). See the non-goals further down for what is deliberately left to you. - RFC 7592 management of the manager's own client, with rotation.
manager.updateClientRegistration(edit: (req) => req..clientName = 'New name')sends the RFC 7592 §2.2PUT— starting from every member the OP previously returned (§2.2: values replace, not augment), with your edit applied — andmanager.readClientRegistration()sends the §2.1GET. Both adopt the response as the manager's identity and persist it: §2.2 says that when the response carries a newclient_secretand/orregistration_access_token"the client MUST immediately discard its previous" ones, so the next back-channel call already presents the rotated secret and the next launch restores it instead of the retired one. Members the response leaves out (registration_access_token,registration_client_uri, andclient_secretunless the client became public) are carried over rather than dropped. The record keeps the fingerprint it was issued for, so an update never triggers a re-registration. A response for a differentclient_idis refused, and calls are serialized so a second call presents the token the first one rotated in. If the store cannot take the write, the manager still switches to the rotated credentials (the old ones are dead) and throws a typedOidcException. CallingOidcEndpoints.updateClientConfigurationdirectly still works, but the manager and the store never see what it rotates. - Where the registration is stored, and why there is no new
OidcStoreNamespace. The record (includingregistration_access_token/registration_client_uri) lives inOidcStoreNamespace.secureTokensunder the keyclient_registration.<issuer>, so it gets the same secure-storage guarantees as the tokens. A dedicated enum value was considered and rejected:OidcStoreNamespaceis an enum that every third-partyOidcStoreswitches over or maps to a storage location, so adding a value is a breaking change for those implementations, and the new namespace would have needed exactly the guaranteessecureTokensalready has. A key prefix insecureTokensis non-breaking, andforgetUser()spares that prefix because the registration identifies the app instance, not the user. - Registered out of band instead? If you call
OidcEndpoints.registerClientyourself (or a backend hands you the response), leavedynamicClientRegistrationunset and passclientCredentials: OidcClientAuthentication.fromRegistrationResponse(response)to the manager's constructor. Keeping that response, and its RFC 7592 credentials, is then up to you. - Public clients are supported. RFC 7591 §3.2.1 makes
client_secretOPTIONAL, so an OP that issues aclient_idalone (and states notoken_endpoint_auth_method) yields a public client authenticating withnone— §2'sclient_secret_basicdefault only applies when a secret WAS issued. - The manager fills the metadata it can derive from your settings when your
buildRequestleaves it null:redirect_uris,scope,post_logout_redirect_uris,grant_types/response_types(which default toauthorization_code+refresh_token/code, because RFC 7591 §2 would otherwise register the client forauthorization_codealone and break the automatic refresh), andapplication_type. Flows you opt into must be declared: addOidcConstants_GrantType.deviceCodefor the device-code flow, orimplicitplus the matchingresponseTypesfor implicit/hybrid. application_typeis derived from your redirect uris, not from the platform. OpenID Connect Registration §2 states it as a client-side rule: anativeclient "MUST only registerredirect_urisusing custom URI schemes or loopback URLs using thehttpscheme", and awebclient MUST usehttpsand MUST NOT uselocalhost. So an Android or iOS app whose redirect is an App Link / Universal Link (https://app.example.com/callback) registers asweb— declaring itnativebecause it runs on a phone authors a request a conformant OP must reject. SetapplicationTypein yourbuildRequestto override.- RFC 7591 §3.2.1 lets the OP substitute its own values; the
scope,redirect_urisandpost_logout_redirect_urisit returns — not the ones that were requested — are what later authorization / token / end-session requests use. Theclient_idandtoken_endpoint_auth_methodfrom the response likewise become the credentials every back-channel call presents. - Loopback redirects are compared without their port (RFC 8252 §7.3: "the authorization server MUST allow any port to be specified at the time of the request for loopback IP redirect URIs"). A desktop app whose listener binds an ephemeral port therefore matches its stored registration on every launch instead of minting a new OP-side client each run, and its authorization request keeps the live port even when the OP registered a different one. Only loopback
httpis normalized; every other uri, includinghttps, compares byte-for-byte (RFC 3986 §6.2.1). - Web is always registered as a PUBLIC client. A browser app has nowhere to keep a secret (OAuth 2.0 for Browser-Based Apps §6.1): the registration lives in
OidcStoreNamespace.secureTokens, which on web is browser storage (localStorageviashared_preferenceswhen noFlutterSecureStorageis passed toOidcDefaultStore), readable by any injected script. So on web the manager asks fortoken_endpoint_auth_method: "none"unless yourbuildRequestsets one — RFC 7591 §2 would otherwise default the client to the confidentialclient_secret_basic— and refuses a response that issues aclient_secretanyway, with a typedOidcExceptionthrown before anything is written. A provider that insists on issuing one is telling you the client belongs on a backend. The same refusal applies to a persisted registration carrying a secret, so a client registered on another platform is never replayed in a browser. - The record is persisted per-issuer in the secure store and re-used on every later launch — including across
forgetUser(), since it is app-instance identity, not user identity. Staleness is measured against the request that was actually registered (after the defaults above are applied), not against your settings alone, so a change made insidebuildRequestcounts too: the fingerprint coversredirect_uris,post_logout_redirect_uris,scope,grant_types,response_typesandapplication_type. It deliberately ignoressoftware_statement,jwksand every purely descriptive member (client_name,logo_uri, …) — those are re-signed or edited on their own schedule, and RFC 7592 §2.2 update is their remedy, not orphaning the OP-side client for a fresh one. - Nothing is ever deleted to make room for something that does not exist yet. There is exactly one write of the registration key and exactly one removal —
forgetClientRegistration(), which you call. A record that cannot be read (a locked keychain, an Android key invalidated by a biometric re-enrolment), cannot be parsed, or cannot be converted into credentials is treated as a cache miss and left exactly where it is; the replacement overwrites it in place once it has been issued and persisted. So an app update that changes the fingerprint and then launches offline keeps running as the previously-issued client — theclient_idand the RFC 7592registration_access_tokensurvive — instead of being stranded with nothing. The write comes before the in-memory swap for the same reason: RFC 7591 registration is not idempotent, so a registration that could not be persisted is reported as a typedOidcExceptionrather than silently minting another OP-side client on every launch. - Non-goals, each with the clause that makes it optional and the call you make instead. All of them are reachable today:
updateClientRegistration()/readClientRegistration()on the manager, the full RFC 7592 client-management surface asOidcEndpoints.registerClient/readClientConfiguration/updateClientConfiguration/deleteClientConfiguration, andOidcUserManagerBase.clientRegistration, which hands you theregistration_client_uriandregistration_access_tokenthey need.- No automatic
client_secretrotation. RFC 7592 App. A.1: "the authorization server decides the frequency of the credential rotation and not the client" — the §2.1 read is an OP-driven affordance, not a client obligation, and an OP that conformantly returns the registration verbatim leaves a client-driven rotation loop with nothing to make progress on. Against an OP that does rotate on a schedule (Zitadel, Keycloak, Okta can be configured to) the session breaks at expiry with a typedOidcException; writecatch → manager.readClientRegistration()(which persists the rotated credentials), orcatch → forgetClientRegistration() → build a new manager and init(). The cost of the latter is a re-login and one orphaned OP-side client. - No automatic re-registration when the provider disowns the client. RFC 6749 §5.2 makes
invalid_clientthree-way ambiguous ("unknown client, no client authentication included, or unsupported authentication method") and OpenID Connect Registration §4.4 forbids the 404 that would disambiguate it, so there is no reliable trigger. The rejection is surfaced to you untouched — nothing is re-registered, nothing is deleted — and the sameforgetClientRegistration()handler applies. 0 of 7 mature RP libraries automate this. - No automatic RFC 7592
PUT/DELETEfrom the manager, and no orphan tracking or reaping. RFC 7591 §5 assigns cleanup of registered-but-unused clients to the authorization server. Orphaned OP-side clients therefore accumulate untracked: one per cache miss, fingerprint change, expired-secret restore, storage fault, cross-manager race and web page load. Nothing counts, caps or reaps them, so an OP with a per-tenant client quota will eventually reject registrations with no hint that quota is the cause. CallOidcEndpoints.deleteClientConfiguration(§2.3) beforeforgetClientRegistration()if you want the OP-side client retired, andmanager.updateClientRegistration()(§2.2) to edit one in place. - No cross-manager, cross-tab or cross-process coordination.
OidcStorehas no compare-and-set, so nothing can be promised: two managers, two browser tabs, or twoOidcStoreobjects over one backing store each mint a client and the last writer wins the key. Give managers that are meant to be independent distinctmanagerIds. - No detection of a
client_idthe OP rotated on its own, and no recovery from a POST whose response was lost in transit (that mints a client no design on any transport can ever name). private_key_jwtregistrations are not automated, because the signing key cannot be derived from a registration response — buildOidcClientAuthentication.privateKeyJwtGeneratedyourself and register the client out of band. Mutual-TLS registrations (tls_client_auth/self_signed_tls_client_auth, RFC 8705) are derived automatically, because they need only theclient_id. The certificate comes from the cert-bearinghttpClient(OidcMtls.createHttpClient), and on web the derived mTLS credentials are rejected with anUnsupportedError.
- No automatic
- Why the non-goals are stated as non-goals rather than gaps: every one of them needs durable control state — a retry budget, an attempt counter, a rejection verdict, a generation number, a ledger of minted clients — and every such measure can be destroyed or reset by the very action it gates. There is none here. The store holds exactly one immutable record per issuer, containing only the OP's response and a digest of the request it was issued for, and
packages/oidc_core/test/dcr_invariants_test.dartcounts that mechanically: one write call site, one removal call site, one read call site, in three different methods, with no read-modify-write anywhere.
- Set
Plugin Generated by the Very Good CLI 🤖
