Skip to content

Instantly share code, notes, and snippets.

@lemmensaxel
Last active September 18, 2024 11:01
Show Gist options
  • Save lemmensaxel/72ece5cd00026cc05888701d7d65fbe0 to your computer and use it in GitHub Desktop.
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.

@luizeduardogiampaoli
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!

@luizeduardogiampaoli
Copy link

luizeduardogiampaoli 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

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