loginWith method

Future<AuthResult> loginWith(
  1. SSO provider, {
  2. SsoLoginOptions? options,
})

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;
  }
}