Skip to content

Sign in & accounts

Everything else on a pod needs a WebID to act as. This is how you get one, keep it, and deal with a device that holds more than one.

On the client path you write no authentication code at all — the host app owns the login. On the api path you run the OIDC flow yourself and the library handles DPoP, nonces and refresh.

What you can build

  • Sign-in that reuses a session the user already has, with no second password prompt.
  • An account switcher, when someone keeps work and personal pods apart.
  • A session that survives app restarts and token expiry without re-prompting.
  • A sign-out that actually ends the relationship rather than just forgetting locally.

Setup

build.gradle.kts
dependencies {
    implementation("com.erfangholami.androidsolidservices:client:0.7.0")
}
import com.erfangholami.androidsolidservices.client.sdk.Solid

val signIn = Solid.getSignInClient(context)
build.gradle.kts
dependencies {
    implementation("com.erfangholami.androidsolidservices:api:0.7.0")
}
import com.erfangholami.androidsolidservices.api.auth.Authenticator

val authenticator = Authenticator.getInstance(context)
build.gradle.kts
android {
    defaultConfig {
        manifestPlaceholders["appAuthRedirectScheme"] = "com.example.yourapp"
    }
}

Recipes

Sign a user in

class MainActivity : ComponentActivity() {

    // Register during creation, not in a click handler. (1)!
    private val authorize = registerForActivityResult(AuthorizeWithSolid()) { result ->
        when (result) {
            is SolidSignInResult.Authorized -> onSignedIn(result.webId)
            SolidSignInResult.Dismissed     -> showMessage("Sign-in cancelled")
            is SolidSignInResult.Failed     -> showError(result.exception)
        }
    }

    private fun startSignIn() = authorize.launch(Unit)
}
  1. Android requires result contracts to be registered before the activity reaches STARTED. Registering later throws.

Your app launches the picker from its own foreground, which is why sign-in needs no permission at all — not even the overlay permission earlier versions asked for.

val (intent, error) = authenticator.createAuthenticationIntent(
    oidcIssuer = "https://solidcommunity.net",
    appName = "Your App",
    redirectUri = "com.example.yourapp:/callback",
    clientId = "https://yourapp.example/client.jsonld",   // (1)!
)
if (intent != null) login.launch(intent)

// …then, in the result callback:
val webId = authenticator.submitAuthorizationResponse(result.data)
  1. Optional but strongly recommended — see the warning below.

Dynamic registration expires

Omit clientId and the library registers a client dynamically with the provider. Inrupt discards those after 24 hours, and the refresh token dies with the registration — which looks exactly like a session that mysteriously drops once a day. Hosting a Client ID Document and passing its URL as clientId is the fix.

Check whether you are already signed in

Do this before showing a sign-in button, or you will prompt someone who is already authorized.

val account = signIn.getAccount(webId)      // null when this app is not authorized
if (account != null) proceed(account.webId) else startSignIn()
if (authenticator.isUserAuthorized()) {
    proceed(authenticator.getActiveWebId())
} else {
    startSignIn()
}

Wait for the connection before calling

On the client path every service is a bound service. Collect its state and wait for true:

signIn.authServiceConnectionState().first { connected -> connected }

Skipping this is the most common cause of a first call failing on a cold start — the binding has not finished. The api path has nothing to wait for.

Handle several accounts

The host app owns the account list. Your app receives whichever WebID the user picked at authorization, and passes it to every call.

val account = signIn.getAccount(webId) ?: return
resources.readStream(account.webId, uri)     // routed to that account's session
val all = authenticator.getAllLoggedInProfiles()
authenticator.setActiveWebId(all.first().webId?.getIdentifier().orEmpty())

// Observe instead of polling:
authenticator.loggedInProfilesFlow.collect { profiles -> render(profiles) }
authenticator.expiredProfilesFlow.collect { expired -> promptReauth(expired) }

Notice an expired session

Expiry is a state, not an error — a profile whose refresh has run out stays in the list, flagged, so you can prompt for re-authentication instead of silently losing the user's data.

authenticator.expiredProfilesFlow.collect { expired ->
    expired.forEach { promptReauth(it) }
}

Sign out

signIn.disconnectFromSolid(webId) { revoked ->
    if (revoked) returnToSignIn()
}

This revokes your app's grant. The user stays signed in to Android Solid Services, and their other apps are unaffected.

val (intent, error) = authenticator.getTerminationSessionIntent(
    webId = webId,
    logoutRedirectUrl = "com.example.yourapp:/logout",
)
if (intent != null) logout.launch(intent)

authenticator.removeProfile(webId)     // drops the local session too

How it flows

sequenceDiagram
    autonumber
    participant App as Your app
    participant ASS as Android Solid Services
    participant IdP as Solid provider
    participant Pod as Solid pod

    App->>ASS: AuthorizeWithSolid.launch()
    ASS->>IdP: authorization request (+ Client ID Document)
    IdP-->>ASS: code, after the user signs in
    Note over ASS: mints a per-account DPoP key<br/>in the Android Keystore
    ASS->>IdP: token request + DPoP proof
    IdP-->>ASS: DPoP-bound access + refresh token
    Note over ASS: validates the ID token, and the<br/>issuer against the WebID
    ASS-->>App: Authorized(webId)

    App->>ASS: any pod call, with that webId
    ASS->>Pod: request + fresh DPoP proof
    Pod-->>ASS: 200
    ASS-->>App: result — never a token

Errors you'll hit

What you see Why What to do
SolidNotLoggedInException no session for that WebID send the user through sign-in again
SolidAppNotFoundException the host app is not installed prompt to install it, or use api
SolidServiceConnectionException called before the binding completed collect the connection-state flow first
A session that dies about once a day dynamic registration expired — Inrupt drops them after 24h host a Client ID Document and pass clientId
401 that re-authenticating does not fix the pod wants a DPoP nonce, not a new login already handled — the library retries with the nonce
Everything 403s after switching account calls are still using the previous WebID pass the new WebID; 403 rather than 401 is the tell

Under the hood

Authentication is where a person's identity — a WebID — becomes a set of credentials this library can put on an HTTP request. It implements Solid-OIDC: the user authenticates at their own identity provider in a browser tab, the library exchanges the result for tokens, and every subsequent request carries an access token plus a DPoP proof bound to a key that never leaves the device. The library holds several such identities at once and keeps each one's tokens, keys and profile separate.

This is also the part of the library that has cost the most real sessions, so the failure behaviour in §5 is the section worth reading twice.

Pod shape

Authentication writes nothing to a pod. It reads the WebID profile document, and what it needs from it is small:

@prefix solid: <http://www.w3.org/ns/solid/terms#> .
@prefix pim:   <http://www.w3.org/ns/pim/space#> .
@prefix foaf:  <http://xmlns.com/foaf/0.1/> .

<https://alice.solidcommunity.net/profile/card#me>
    solid:oidcIssuer  <https://solidcommunity.net> ;
    pim:storage       <https://alice.solidcommunity.net/> ;
    foaf:name         "Alice" .

solid:oidcIssuer is the only field authentication strictly requires: it names who is allowed to issue tokens for this WebID, and it is what makes a WebID self-describing rather than tied to a directory. pim:storage is read at the same time because every later feature needs it.

The profile document is fetched with readPublic — an unauthenticated read — when it belongs to somebody else. That is not an optimisation: several identity providers answer an authenticated read of a third party's profile with 401, so an authenticated fetch is strictly worse.

Everything else lives on the device: tokens, the DPoP key pair in the Android Keystore, the account list, and each session's error state. The library never publishes a device's key or its session state to the pod.

Public surface

Authenticator (api/src/main/java/com/erfangholami/androidsolidservices/api/auth/Authenticator.kt) is the whole surface. It initialises asynchronously, which shapes how it must be called.

Member Notes
activeWebIdFlow, activeAccountFlow The current identity
loggedInProfilesFlow, expiredProfilesFlow The two lists an account switcher needs
getActiveWebId() Suspending, and it awaits initialisation. This is the one to call first.
isUserAuthorized() Synchronous, and only accurate after initialisation has completed
createAuthenticationIntentWithOidcIssuer(clientName, issuer, redirectUri) Suspending; returns the browser intent plus an optional error
submitAuthorizationResponse(intent) Completes the code exchange, returns the WebID
setActiveWebId, removeProfile, removeAllProfiles Switching and sign-out
hasValidToken(webId, forceRefresh) From [SolidSession]: ensures a usable token, reports availability
authHeaders(webId, method, uri) From [SolidSession]: the token plus a DPoP proof bound to this request
updateDPoPNonce(webId, uri, nonce) Stores a server-issued DPoP nonce per origin

Consumers should not call isUserAuthorized() synchronously as an initial value at startup: it returns false before initialisation completes, which routes a signed-in user to a login screen. Await getActiveWebId() first.

How it flows

First login

  1. The consumer calls createAuthenticationIntentWithOidcIssuer. The library resolves the issuer's OIDC discovery document, registers or reuses the client, generates a DPoP key pair in the Keystore, and builds an AppAuth authorization request.
  2. The consumer launches the returned intent. The user authenticates at their provider.
  3. The provider redirects to the registered redirect URI, which must land on AppAuth's RedirectUriReceiverActivity.
  4. submitAuthorizationResponse exchanges the code for tokens, with a DPoP proof on the token request (DPoPTokenRequester).
  5. IdTokenVerifier validates the ID token against the issuer's JWKS — signature, issuer, audience, expiry — and extracts the WebID.
  6. The WebID's profile is fetched, and the account is stored with its tokens and key alias.

The redirect URI must appear verbatim in the hosted Client ID Document's redirect_uris. Providers compare it as a string, and a trailing-slash difference is a rejected login.

The session seam

Authenticator extends the public SolidSession (api/src/main/java/com/erfangholami/androidsolidservices/api/auth/SolidSession.kt): three AppAuth-free methods — hasValidToken, authHeaders, updateDPoPNonce — that are everything the transport layer knows about identity. SolidHttpClient, the notification transport and the WebSocket client each take a SolidSession, never the authenticator; the internal asSession() downcast that used to bind them to the one true singleton is gone, and a test fakes the whole of auth in three methods.

Attaching credentials to a request

Every authenticated request goes through SolidHttpClient.sendWithAuthRetry (api/src/main/java/.../api/transport/SolidHttpClient.kt:713):

  1. Build the headers: the access token, plus a DPoP proof signed over this method and URI with the origin's current nonce.
  2. Send.
  3. Store any DPoP-Nonce the server returned, keyed by origin, so the next request to that origin carries it.
  4. If the response is not 401, return it.
  5. Otherwise classify the challenge and decide — see below.

The loop runs at most MAX_AUTH_ATTEMPTS (3) times.

Refresh

TokenRefreshCoordinator owns the machinery; RefreshPolicy owns every decision (expiry lead time, result coalescing, the forced-refresh cooldown, 429 backoff windows), so the policy is unit-tested on a fake clock without a token endpoint in sight.

A refresh runs as a single flight on the coordinator's own scope: concurrent callers for one WebID share one Deferred, and cancelling a caller cancels only its await — the rotation itself completes and persists regardless. This is structural, not conventional: the old design ran the rotation on the caller's cancellable coroutine and survived only by remembering NonCancellable at the right places; a UI flow tearing down mid-POST once lost a rotated refresh token that way, re-sent the spent one, and cost the whole grant family. TokenRefreshOwnScopeTest pins all three properties: caller cancellation does not lose the rotation, N callers spend the token once, and a just-completed result is coalesced instead of re-fetched.

What a session is at any moment is decided in exactly one place: SessionState (Active | NeedsReauth(reason) | Revoked), deliberately blind to whether the login ever completed — a revoked grant stays revoked no matter how partial the profile around it is. The logged-in and expired account flows, the dead-session gate and the active-account reconciler are all projections of it.

Failure behaviour

A 401 is two different answers wearing one status code

This is the single most important behaviour in the library, because getting it wrong destroys sessions rather than merely failing a request.

A 401 can mean "your token is stale" or "you may not have this". Only the first is worth a refresh. When the library treated every 401 as expiry, an ordinary cross-pod read of a resource the user was not authorized for produced refresh traffic; a screen doing several such reads produced a burst; the burst hit provider rate limits; and on providers that revoke an entire refresh-token family when a token appears to be replayed, the whole session died — from an operation that was never supposed to touch the identity provider at all.

AuthChallenge (api/src/main/java/.../api/transport/AuthChallenge.kt) classifies the WWW-Authenticate header into four cases:

Case Recognised by What happens
NonceStale use_dpop_nonce, without a token error Retry with the nonce the server just gave us. No refresh.
TokenExpired invalid_token or expired_token Refresh. The server named the token.
NotAuthorized insufficient_scope or invalid_request Return the 401. Refreshing cannot change the answer.
Unspecified anything else, including no header Origin decides — see below.

A nonce challenge that also names a bad token is classified as TokenExpired, because the token problem is the one that needs solving.

For Unspecified, warrantsTokenRefresh(requestIsOwnOrigin) decides by origin: a 401 from the identity's own pod without a machine-readable reason is plausibly expiry and is refreshed; a 401 from any other origin is treated as a denial. The reasoning is that a server which meant "your token expired" would have said so, and refreshing against a foreign pod's silence buys nothing while costing a round trip to the identity provider. isOwnOrigin compares the request's origin to the WebID's; an unparseable WebID is treated as its own origin, so a malformed identity degrades to the old, more eager behaviour rather than to no refresh at all.

Both outcomes are reported through the Telemetry sink — one line for "kept as authorization outcome", one for "forced refresh" — each carrying the origin and a truncated header, so a session death can be reconstructed from a report without logging credentials.

Sessions that were never refreshable

Some providers issue a refresh token only when the user ticks a "remember me" box. Without one the session simply ends when the access token expires, and no amount of correct refresh logic saves it. The library records this at login so a consumer can say something truthful instead of "logged out unexpectedly".

Expiry is a state, not an error

A session that cannot be refreshed moves from loggedInProfilesFlow to expiredProfilesFlow, carrying a sessionError. The account remains known. This matters because dropping the account would also drop everything scoped to it on the device, including work the user has queued but not yet synced.

Keystore and store failures

The DPoP key lives in the Android Keystore, which can drop keys — after certain device state changes, the key a session was bound to is simply gone. The library treats a decrypt failure of the profile store as transient and strike-counted rather than as corruption, because wiping on the first failure turned a recoverable hiccup into a forced re-login for every account.

When corruption is finally conceded, the store is quarantined, never wiped: the unreadable file is copied aside as profiles.json.quarantined.<timestamp> before an empty store replaces it, the event is reported as store_quarantined, and the user is asked to sign in again — with the original bytes preserved for recovery instead of destroyed (UserRepositoryImplementation.kt, pinned by ProfileStoreQuarantineTest).

Account reads are store-first by construction: ProfileManager persists every mutation and publishes it to its in-memory state inside one write lock, and every flow and synchronous getter projects from that state. There is no cached flow lagging a write and no read-your-writes overlay compensating for one — the design that let a stale emission silently switch the active account is gone rather than patched.

Extension points
  • Telemetry is the seam for observability: a consumer installs a sink, and the library reports auth events into it without depending on any analytics SDK. This is how the app's Firebase reporting works without the library knowing Firebase exists.
  • Authenticator is an interface, with getInstance(...) factories. Consumers depend on the interface; the app wraps it once more in its own AuthRepository so a library-side change to the auth surface has exactly one adaptation point.
Tests

api/src/test/java/.../api/transport/AuthChallengeTest.kt is the file to read before changing refresh behaviour, because it encodes decisions rather than mechanics:

  • a nonce challenge is not an expiry;
  • a nonce challenge that also names a bad token still refreshes;
  • a named token problem refreshes wherever it happens;
  • an authorization problem never refreshes;
  • a bare 401 from a foreign pod is a denial — the case that produced the refresh storm, and the assertion carries that sentence as its failure message;
  • a bare 401 from the identity's own pod still refreshes;
  • a missing header is Unspecified, not an error;
  • an unparseable identity is treated as its own origin.

TokenRefreshCoordinatorTest covers the serialization and terminal-failure behaviour.

Specifications