Skip to content

Instantly share code, notes, and snippets.

@lemmensaxel
Last active November 7, 2025 15:43
Show Gist options
  • Select an option

  • Save lemmensaxel/72ece5cd00026cc05888701d7d65fbe0 to your computer and use it in GitHub Desktop.

Select an option

Save lemmensaxel/72ece5cd00026cc05888701d7d65fbe0 to your computer and use it in GitHub Desktop.
React-native expo + keycloak PKCE flow implemented using expo AuthSession
import {
ActivityIndicator,
Button,
ScrollView,
Text,
View,
} from "react-native";
import * as AuthSession from "expo-auth-session";
import * as WebBrowser from "expo-web-browser";
import { useEffect, useState } from "react";
WebBrowser.maybeCompleteAuthSession();
const redirectUri = AuthSession.makeRedirectUri({
useProxy: true,
});
// Keycloak details
const keycloakUri = "";
const keycloakRealm = "";
const clientId = "";
export function generateShortUUID() {
return Math.random().toString(36).substring(2, 15);
}
export default function App() {
const [accessToken, setAccessToken] = useState<string>();
const [idToken, setIdToken] = useState<string>();
const [refreshToken, setRefreshToken] = useState<string>();
const [discoveryResult, setDiscoveryResult] =
useState<AuthSession.DiscoveryDocument>();
// Fetch OIDC discovery document once
useEffect(() => {
const getDiscoveryDocument = async () => {
const discoveryDocument = await AuthSession.fetchDiscoveryAsync(
`${keycloakUri}/realms/${keycloakRealm}`
);
setDiscoveryResult(discoveryDocument);
};
getDiscoveryDocument();
}, []);
const login = async () => {
const state = generateShortUUID();
// Get Authorization code
const authRequestOptions: AuthSession.AuthRequestConfig = {
responseType: AuthSession.ResponseType.Code,
clientId,
redirectUri: redirectUri,
prompt: AuthSession.Prompt.Login,
scopes: ["openid", "profile", "email", "offline_access"],
state: state,
usePKCE: true,
};
const authRequest = new AuthSession.AuthRequest(authRequestOptions);
const authorizeResult = await authRequest.promptAsync(discoveryResult!, {
useProxy: true,
});
if (authorizeResult.type === "success") {
// If successful, get tokens
const tokenResult = await AuthSession.exchangeCodeAsync(
{
code: authorizeResult.params.code,
clientId: clientId,
redirectUri: redirectUri,
extraParams: {
code_verifier: authRequest.codeVerifier || "",
},
},
discoveryResult!
);
setAccessToken(tokenResult.accessToken);
setIdToken(tokenResult.idToken);
setRefreshToken(tokenResult.refreshToken);
}
};
const refresh = async () => {
const refreshTokenObject: AuthSession.RefreshTokenRequestConfig = {
clientId: clientId,
refreshToken: refreshToken,
};
const tokenResult = await AuthSession.refreshAsync(
refreshTokenObject,
discoveryResult!
);
setAccessToken(tokenResult.accessToken);
setIdToken(tokenResult.idToken);
setRefreshToken(tokenResult.refreshToken);
};
const logout = async () => {
if (!accessToken) return;
const redirectUrl = AuthSession.makeRedirectUri({ useProxy: false });
const revoked = await AuthSession.revokeAsync(
{ token: accessToken },
discoveryResult!
);
if (!revoked) return;
// The default revokeAsync method doesn't work for Keycloak, we need to explicitely invoke the OIDC endSessionEndpoint with the correct parameters
const logoutUrl = `${discoveryResult!
.endSessionEndpoint!}?client_id=${clientId}&post_logout_redirect_uri=${redirectUrl}&id_token_hint=${idToken}`;
const res = await WebBrowser.openAuthSessionAsync(logoutUrl, redirectUrl);
if (res.type === "success") {
setAccessToken(undefined);
setIdToken(undefined);
setRefreshToken(undefined);
}
};
if (!discoveryResult) return <ActivityIndicator />;
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
{refreshToken ? (
<View
style={{ flex: 1, justifyContent: "center", alignItems: "center" }}
>
<View>
<ScrollView style={{ flex: 1 }}>
<Text>AccessToken: {accessToken}</Text>
<Text>idToken: {idToken}</Text>
<Text>refreshToken: {refreshToken}</Text>
</ScrollView>
</View>
<View>
<Button title="Refresh" onPress={refresh} />
<Button title="Logout" onPress={logout} />
</View>
</View>
) : (
<Button title="Login" onPress={login} />
)}
</View>
);
}
@gabgagnon
Copy link

Thank you for your example, super cool as the Expo documentation is lacking important sections of the process.

@legiampaoli
Copy link

Hello, thanks for the example! I am using and it is working. This is my first contact with Expo AuthSession. But there is a problem: everytime I try to login again Keycloak remebers my e-mail, but asks for the password again. This does not happens with Postman, or other web front-ends... I think this is related to the following section in Expo AuthSession documentation:

"Note: the web browser should share cookies with your system web browser so that users do not need to sign in again if they are already authenticated on the system browser -- Expo's WebBrowser API takes care of this."

If the API takes care of this, something is wrong and I did not found a way to tune... Anyone experiencing this? Maybe this is related to a development build? Maybe Chrome in development build is restricting cookies?

Thanks!

@legiampaoli
Copy link

legiampaoli commented Apr 17, 2024

Answering my own question : prompt: AuthSession.Prompt.Login at line 51, according to OpenId documentation here it will make the server prompt for reauthentication. It says: "The Authorization Server SHOULD prompt the End-User for reauthentication". Keycloak is doing exactly that. To solve my case, I just had to not send the prompt parameter. Now it works as I expect: it will only promt for login again in case of complete timeout without refresh. πŸ˜„ πŸ˜ƒ πŸ’―

@lemmensaxel
Copy link
Author

Hi @SHUHAIB-T, a screenshots of my client configuration:
image

This is the JSON export:

{
    "clientId": "app",
    "name": "AlertCore Mobiele Applicatie",
    "surrogateAuthRequired": false,
    "enabled": true,
    "alwaysDisplayInConsole": false,
    "clientAuthenticatorType": "client-secret",
    "redirectUris": [
        "alert-core://*",
        "https://auth.expo.io/@lemmensaxel/*",
        "exp://*"
    ],
    "webOrigins": [],
    "notBefore": 0,
    "bearerOnly": false,
    "consentRequired": false,
    "standardFlowEnabled": true,
    "implicitFlowEnabled": false,
    "directAccessGrantsEnabled": true,
    "serviceAccountsEnabled": false,
    "publicClient": true,
    "frontchannelLogout": false,
    "protocol": "openid-connect",
    "attributes": {
        "saml.multivalued.roles": "false",
        "saml.force.post.binding": "false",
        "frontchannel.logout.session.required": "false",
        "oauth2.device.authorization.grant.enabled": "true",
        "backchannel.logout.revoke.offline.tokens": "false",
        "saml.server.signature.keyinfo.ext": "false",
        "use.refresh.tokens": "true",
        "oidc.ciba.grant.enabled": "false",
        "backchannel.logout.session.required": "true",
        "client_credentials.use_refresh_token": "false",
        "saml.client.signature": "false",
        "require.pushed.authorization.requests": "false",
        "saml.allow.ecp.flow": "false",
        "saml.assertion.signature": "false",
        "id.token.as.detached.signature": "false",
        "saml.encrypt": "false",
        "saml.server.signature": "false",
        "exclude.session.state.from.auth.response": "false",
        "saml.artifact.binding": "false",
        "saml_force_name_id_format": "false",
        "tls.client.certificate.bound.access.tokens": "false",
        "acr.loa.map": "{}",
        "saml.authnstatement": "false",
        "display.on.consent.screen": "false",
        "token.response.type.bearer.lower-case": "false",
        "saml.onetimeuse.condition": "false"
    },
    "authenticationFlowBindingOverrides": {},
    "fullScopeAllowed": true,
    "nodeReRegistrationTimeout": -1,
    "protocolMappers": [
        {
            "name": "Realm mapper",
            "protocol": "openid-connect",
            "protocolMapper": "oidc-hardcoded-claim-mapper",
            "consentRequired": false,
            "config": {
                "claim.value": "alert-core-tenant1",
                "userinfo.token.claim": "true",
                "id.token.claim": "true",
                "access.token.claim": "true",
                "claim.name": "realm",
                "jsonType.label": "String",
                "access.tokenResponse.claim": "false"
            }
        },
        {
            "name": "Phone mapper",
            "protocol": "openid-connect",
            "protocolMapper": "oidc-usermodel-attribute-mapper",
            "consentRequired": false,
            "config": {
                "userinfo.token.claim": "true",
                "user.attribute": "phone",
                "id.token.claim": "true",
                "access.token.claim": "true",
                "claim.name": "phone",
                "jsonType.label": "String"
            }
        },
        {
            "name": "Competences mapper",
            "protocol": "openid-connect",
            "protocolMapper": "oidc-usermodel-attribute-mapper",
            "consentRequired": false,
            "config": {
                "userinfo.token.claim": "true",
                "multivalued": "true",
                "user.attribute": "competences",
                "id.token.claim": "true",
                "access.token.claim": "true",
                "claim.name": "competences",
                "jsonType.label": "String"
            }
        }
    ],
    "defaultClientScopes": [
        "web-origins",
        "acr",
        "roles",
        "profile",
        "email"
    ],
    "optionalClientScopes": [
        "address",
        "phone",
        "offline_access",
        "microprofile-jwt"
    ],
    "access": {
        "view": true,
        "configure": true,
        "manage": true
    }
}

@SHUHAIB-T
Copy link

thank you @lemmensaxel

@AntoninBarbier
Copy link

AntoninBarbier commented Sep 3, 2025

Thanks for the example, it has been very useful, but i'm still scratching my head on redirect URIs, trying to make my authentication worflow work on Android and IOS simulators and also Web, using expo managed workflow and development builds :
First, it seems using { useProxy : true } like you do in your code is deprecated now, as stated here : https://auth.expo.io/

Then, when building my redirectUri like that :
const redirectUri = AuthSession.makeRedirectUri();
When doing the promptAsync(), I'm getting the keycloak error : Invalid parameter : redirect_uri error

I found out that I don't get any error redirect_uri error when specify a path, like :
const redirectUri = AuthSession.makeRedirectUri({ path: "redirect", });

And then everything works fine on IOS and Web (login, logout...).

But on Android, because of what I think is called Deep Linking, this path: 'redirect' makes the app to be redirected to the /redirect screen which does not exist on my app.
This solves it for IOS and web, and everything is working fine, but it crashes on Android because of the missing screen.

So i had this idea :
const redirectUri = AuthSession.makeRedirectUri({ path: "/", });

So that i'm redirected to the home screen after keycloak login is complete. And it works ! But when trying to logout, i get an error in the keycloak popup : We are sorry... An internal server error has occured. The same error occurs on IOS.

So I'm still stuck and any help would be very useful.

@datmt
Copy link

datmt commented Sep 26, 2025

@AntoninBarbier I've configured successfully and made the post here: https://datmt.com/mobile-development/configure-expo-login-with-keycloak/
Also, my demo repo is here so you can take a look https://github.com/datmt/expo-keycloak
Hope that helps

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment