loginWith method
Signs in with provider, opening its sign-in page in the system browser.
Returns once the user is back in the app. Check the result's status: AuthStatus.authenticated is done, AuthStatus.linkRequired means the email already has a password account and needs linkSsoAccount, and AuthStatus.pendingTwoFactorAuthCode needs verifyTwoFactorAuthCode.
An existing session is only replaced once the provider has returned successfully, so a user who abandons this stays signed in as they were.
Throws SsoCancelledException if the user dismisses the sign-in sheet.
Implementation
Future<AuthResult> loginWith(SSO provider, {SsoLoginOptions? options}) async {
final redirectUri =
options?.redirectUri ?? defaultSsoRedirectUri(_config.getApiUrl());
validateSsoRedirectUri(redirectUri);
// A fresh attempt supersedes a link the user never finished.
_pendingLinkToken = null;
try {
// Binds the one-time code to this app: intercepting the callback is not
// enough to redeem it without the verifier, which never leaves memory.
final pkce = createSsoPkcePair();
// No `state` parameter, deliberately. Its job is CSRF - stopping a code
// the user did not ask for from being redeemed in their session - and the
// OAuth 2.0 Security BCP accepts PKCE in its place once the code is bound
// to the client, which the verifier above does. Two further checks stand
// behind it: the operating system verifies domain ownership of the HTTPS
// callback before handing it over, and `SsoBrowser` rejects a callback
// that did not arrive at the URL we asked for.
//
// If a reviewer greps for `state` and finds nothing, this is the argument.
dynamic startJson = await _post(
_config.getAuthUrl('/oauth/${provider.id}/start'),
{
'redirectUri': redirectUri,
'mode': 'redirect',
'codeChallenge': pkce.challenge,
'codeChallengeMethod': 'S256',
// Omitted rather than sent as null when the caller names none, so
// the API falls back to the provider's default account.
if (options?.accountKey != null) 'accountKey': options!.accountKey,
},
skipAuthHeader: true,
);
final callbackUri = await _ssoBrowser.authenticate(
authorizationUrl: validateAuthorizationUrl(
startJson is Map ? startJson['authorizationUrl'] : null,
),
redirectUri: Uri.parse(redirectUri),
preferEphemeral: options?.preferEphemeralSession ?? false,
);
final outcome = resolveSsoCallbackParams(
code: callbackUri.queryParameters['code'],
status: callbackUri.queryParameters['status'],
error: callbackUri.queryParameters['error'],
);
final exchange = switch (outcome) {
SsoCallbackNone() => throw const SsoRedirectException(
'Invalid SSO redirect',
),
SsoCallbackError(
message: final message,
providerError: final providerError,
) =>
throw SsoRedirectException(message, providerError: providerError),
SsoCallbackExchange result => result,
};
// The point of no return, and the first thing here to touch the session.
// Signing out any earlier would mean a user who dismisses the sign-in
// sheet loses the session they already had.
if (_hasAuthState()) {
await logout();
}
try {
return await _exchangeSsoCode(
exchange.code,
exchange.status,
pkce.verifier,
);
} catch (err) {
// Past the point of no return, so a failure must not leave the new
// session half-established.
await _clearAuth();
// Redeeming the code is a sign-in, so it can be refused for the same
// reason the password paths can: the address belongs to an SSO account
// the caller did not use. The API resolves a returning user by their
// provider subject id, so nothing earlier in the flow has looked at
// the address they actually hold - which makes this the only place
// that rejection can appear. The account they must use instead travels
// in the body, and carrying it is the whole point of the typed
// exception. Anything else passes through unchanged.
throw _asSsoRequired(err);
}
} catch (err) {
_pendingLinkToken = null;
rethrow;
}
}