Invalid login when calling _session endpoint of Public REST API

:uk:

Hello, as part of the migration of our Flutter app from Firebase to Couchbase, we also need to migrate Firebase Auth. We have decided to use a Keycloak server. Logging in to this already works without any problems. The user logs in with a Google account, sends the access token to the Keycloak server, which then performs a token exchange. The response from the Keycloak server then contains a new set of tokens (access_token, expires_in, refresh_expires_in, refresh_token, token_type=“Bearer”, id_token, session_state, scope=“email profile openid”), which can then be used to authenticate with the Keycloak server. There is a tutorial from Couchbase on using Keycloak together with Couchbase. We followed this regarding the configuration of Keycloak and the Sync Gateway, so we configured Keycloak as a provider:

"keycloak": {
    "issuer":"https://*DOMAIN*/realms/*REALM*",
    "client_id":"sync-gateway",
    "validation_key": "l7G4As1dP61dIeSgnFvc0kDFx63n5mNE",
    "scope": [
        "openid",
        "email",
        "profile"
    ],
    "include_access": true,
    "disable_session": false,
    "register": true
}

The only difference to the tutorial is that we do not use username and password to register and login with Keycloak, instead we use Google as the identity provider. The result, the response described above after the token exchange should be the same. After the token exchange described above, we now want to authenticate the user with the ID token to the Sync Gateway to get a session and configure the replicator with the session. This is how it is described in the tutorial above. After an ID token is fetched from the Keycloak server, it is passed into a createSessionCookie function where it is then sent as an Authorization header to the _session endpoint of the Sync Gateway Public REST API. The expectation is that a session will then be created and returned. This is the code from the tutorial for this function:

public static Cookie createSessionCookie(String idTokenValue) {

  HttpResponse<String> response3 = Unirest.post("http://sync-gateway:4984/french_cuisine/_session") <1>
      .header("Authorization", "Bearer " + idTokenValue).asString();

  System.out.println(" >>>> " + response3.getBody());

  Iterator<Cookie> it = response3.getCookies().iterator(); 
  Cookie resCookie = null;

  while (it.hasNext()) {
    Cookie cookie = it.next();
    if (StringConstants.SG_COOKIE_NAME.equals(cookie.getName())) {
      resCookie = cookie; <2>
      break;
    }
  }

  return resCookie;
}

We have adapted this function for Dart:

static Future getSessionWithIdToken(String idToken) async {
    // Create a request to the Sync Gateway endpoint to get the session
    final http.Response response = await http.post(
      Uri.parse('https://*DOMAIN*/*BUCKET*/_session'),
      headers: {'Authorization': 'Bearer $idToken'},
    );

    // Parse the response body to get the session information
    final Map<String, dynamic> body = Map<String, dynamic>.from(jsonDecode(response.body) as Map);

    print(body);
    // Return the session
    // …
}

Up to the point of executing this function, the whole login process is already working. We get tokens from Keycloak after sign in with Google. Now the ID token is used to execute the above function. However, the http request fails with the status code 401 and the body {"error": "Unauthorized", "reason": "Invalid login"}. In the log from the sync gateway only the following can be seen:

POST /*BUCKET*/_session
--> 401 Invalid login  (0.6 ms)

What I noticed about the tutorial is that the documentation of the _session endpoint does not show the possibility to send an ID token via an Authorization header and thus create a session. Instead, name and password are expected. So apparently either the documentation is wrong/incomplete or the tutorial is outdated. If we figured this out correctly, this is the code for the _session endpoint. In the code I found the following comment:

// NOTE: handleSessionPOST doesn't handle creating users from OIDC - checkAuth calls out into AuthenticateUntrustedJWT.
// Therefore, if by this point `h.user` is guest, this isn't creating a session from OIDC.

Otherwise, the code seems to me to fit more with the endpoint documentation, I don’t see an implementation for creating a session with an ID token here. Since the error Invalid login is returned, I think the function makeSessionWithTTL is being executed by the endpoint. This is used by the makeSession function, which is executed like this:

// If we fail to get a user from the body and we've got a non-GUEST authenticated user, create the session based on that user
if user == nil && h.user != nil && h.user.Name() != "" {
	return h.makeSession(h.user)
} else {
	if err != nil {
		return err
	}
	return h.makeSession(user)
}

I assume that here user is passed to the function and not h.user and that user is nil. This is how I would understand what happens here.

However, now the question is how to solve the problem. We don’t get more than the error Invalid login. So further debugging seems to be difficult. As you can see, we already tried to investigate the issue further, but unfortunately we don’t get any further now. Thanks for the help!

:de:

Hallo. Im Rahmen der Umstellung unserer Flutter App von Firebase zu Couchbase müssen wir natürlich auch Firebase Auth migrieren. Wir haben uns dabei dazu entschieden einen Keycloak Server zu verwenden. Die Anmeldung bei diesem funktioniert bereits problemlos. Der Nutzer meldet sich mit einem Google-Konto an, sendet den Access Token an den Keycloak Server, welcher dann einen Token Exchange durchführt. Die Response vom Keycloak Server enthält dann ein neues Set an Tokens (access_token, expires_in, refresh_expires_in, refresh_token, token_type=“Bearer”, id_token, session_state, scope=“email profile openid”), die dann der Authentifizierung beim Keycloak Server dienen. Es gibt ein Tutorial von Couchbase zum Einsatz von Keycloak in Verbindung mit Couchbase. Diesem sind wir unter anderem hinsichtlich der Konfiguration von Keycloak und dem Sync Gateway gefolgt, zum Beispiel haben wir Keycloak so als Provider konfiguriert:

"keycloak": {
    "issuer":"https://*DOMAIN*/realms/*REALM*",
    "client_id":"sync-gateway",
    "validation_key": "l7G4As1dP61dIeSgnFvc0kDFx63n5mNE",
    "scope": [
        "openid",
        "email",
        "profile"
    ],
    "include_access": true,
    "disable_session": false,
    "register": true
}

Der einzige Unterschied ist, dass die Anmeldung bei Keycloak nicht über Nutzername und Passwort erfolgt, sondern über Google als Identity Provider. Das Resultat, die oben beschriebene Response nach einem Token Exchange sollte dasselbe sein. Nach dem oben beschriebenen Token Exchange wollen wir den Nutzer nun mit dem ID-Token beim Sync Gateway authentifizieren, um eine Session zu bekommen und den Replikator mit der Session zu konfigurieren. So ist es auch in dem oben genannten Tutorial beschrieben. Nachdem ein ID-Token vom Keycloak Server geholt wurde, wird dieser in eine createSessionCookie-Funktion weitergegeben, in welcher dieser dann als Authorization header an den _session Endpoint der Sync Gateway Public REST API geschickt wird. Zu erwarten ist, dass dann eine Session erstellt und zurückgegeben wird. Dies ist der Code aus dem Tutorial für diese Funktion:

public static Cookie createSessionCookie(String idTokenValue) {

  HttpResponse<String> response3 = Unirest.post("http://sync-gateway:4984/french_cuisine/_session") <1>
      .header("Authorization", "Bearer " + idTokenValue).asString();

  System.out.println(" >>>> " + response3.getBody());

  Iterator<Cookie> it = response3.getCookies().iterator(); 
  Cookie resCookie = null;

  while (it.hasNext()) {
    Cookie cookie = it.next();
    if (StringConstants.SG_COOKIE_NAME.equals(cookie.getName())) {
      resCookie = cookie; <2>
      break;
    }
  }

  return resCookie;
}

Diese Funktion haben wir für Dart angepasst:

static Future getSessionWithIdToken(String idToken) async {
    // Create a request to the Sync Gateway endpoint to get the session
    final http.Response response = await http.post(
      Uri.parse('https://*DOMAIN*/*BUCKET*/_session'),
      headers: {'Authorization': 'Bearer $idToken'},
    );

    // Parse the response body to get the session information
    final Map<String, dynamic> body = Map<String, dynamic>.from(jsonDecode(response.body) as Map);

    print(body);
    // Return the session
    // …
}

Bis zum Punkt der Ausführung dieser Funktion funktioniert der gesamte Anmeldeprozess bereits, wir bekommen von Keycloak nach der Anmeldung mit Google die oben genannten Tokens. Nun wird mit dem ID-Token diese Funktion ausgeführt. Die http-Request schlägt jedoch mit dem Status Code 401 und dem Body {"error":"Unauthorized","reason":"Invalid login"} fehl. Im Log vom Sync Gateway ist nur folgendes zu sehen:

POST /*BUCKET*/_session
--> 401 Invalid login  (0.6 ms)

Was mich bei dem Tutorial verwundert ist, dass die Dokumentation des _session Endpoints nicht die Möglichkeit aufzeigt einen ID-Token über einen Authorization Header zu schicken und damit eine Session zu erstellen. Stattdessen wird name und password erwartet. Scheinbar ist also entweder die Dokumentation falsch/unvollständig oder das Tutorial veraltet. Wenn wir das richtig herausgefunden haben, ist dies der Code für den _session Endpoint. Im Code habe ich folgenden Kommentar gefunden:

// NOTE: handleSessionPOST doesn't handle creating users from OIDC - checkAuth calls out into AuthenticateUntrustedJWT.
// Therefore, if by this point `h.user` is guest, this isn't creating a session from OIDC.

Ansonsten scheint mir der Code auch eher zu der Dokumentation des Endpoints zu passen, eine Implementation für das Erstellen einer Session mit einem ID-Token sehe ich hier nicht. Da der Fehler Invalid login ausgegeben wird, muss meines Erachtens auf jeden Fall die Funktion makeSessionWithTTL von dem Endpoint ausgeführt werden. Diese wird von der Funktion makeSession genutzt, welche bei der Implementation des Endpoints hier ausgeführt wird:

// If we fail to get a user from the body and we've got a non-GUEST authenticated user, create the session based on that user
if user == nil && h.user != nil && h.user.Name() != "" {
	return h.makeSession(h.user)
} else {
	if err != nil {
		return err
	}
	return h.makeSession(user)
}

Ich gehe davon aus, dass hierbei user übergeben wird und nicht h.user und dass user hier nil ist. So würde ich den Ablauf hier verstehen.

Wie auch immer ist nun ist die Frage, wie sich das Problem lösen lässt. Mehr als den Fehler Invalid login bekommen wir nicht. Weiteres Debugging scheint also schwierig. Wie man sieht, haben wir den Fehler schon versucht weiter zu untersuchen, kommen jetzt aber leider nicht weiter. Vielen Dank für die Hilfe!

I don’t think there is an issue with the session handler code, but also feel like the API docs for it are confusing, since they don’t make it clear that the body is optional if the request is authenticated otherwise. Authentication in headers or cookies is checked in checkAuth at an earlier point.

I would recommend increasing logging.console.log_level to debug or even trace and add Auth (or * to enable all log keys) to logging.console.log_keys. That should give you more information about why authentication is failing and AuthenticateUntrustedJWT is logging information that should be helpful.

After setting the log level to debug, we got this log:

Auth+: AuthenticateUntrustedJWT called with token: <ud>*MY ID TOKEN*</ud>
Auth+: JWT issuer: <ud>https://*DOMAIN*/realms/*REALM*</ud>, audiences: [ <ud>google-token-exchange-client</ud> ]
Auth+: Call GetProviderForIssuer w/ providers: <ud>map[keycloak:0xc002f1cfc0]</ud>
Auth+: GetProviderForIssuer with issuer: <ud>https://*DOMAIN*/realms/*REALM*</ud>, audiences: [ <ud>google-token-exchange-client</ud> ]
Auth+: No provider match found
Auth+: Provider for issuer: <ud><nil></ud>
HTTP:  #021: POST /*BUCKET*/_session
HTTP: #021:     --> 401 Invalid login  (5.4 ms)

We then searched for the error No provider match found in the Sync Gateway code, which lead us to the GetProviderForIssuer function.

This function contains the following code:

for _, provider := range opm {
	clientID := base.StringDefault(provider.ClientID, "")
	if provider.Issuer == issuer && clientID != "" {
		// Iterate over the audiences looking for a match
		for _, aud := range audiences {
			if clientID == aud {
				base.DebugfCtx(ctx, base.KeyAuth, "Provider matches, returning")
				return provider
			}
		}
	}
}

At this point we found out that the ID Token needs to contain the Client ID of the OIDC provider as an audience, but as the ID Token we sent to the Sync Gateway is issued by a different Keycloak Client (which is responsible for the token exchange with Google), it only contained this other client as an audience and not the Sync Gateway client. We therefore had to add the Sync Gateway Keycloak client to the audiences of the tokens returned by the token exchange client. This can be done by creating a new client scope with a mapper of type Audience and the Included Custom Audience set to the Client ID of our Sync Gateway Keycloak client and then adding this client scope to the token exchange client.
By doing so, we can successfully create a session.

As far as we have now understood the problem there is actually no mistake in the tutorial, because the problem has only arisen due to the change that the login is not with username and password but with Google. Therefore, the tutorial should work without any problems. Nevertheless, the documentation of the _session endpoint is of course a bit too short, since it does not show the other authentication options like via headers.

However, we are of course very happy that the problem could be solved with this. Thanks for the support!

1 Like