Last active
April 27, 2025 05:26
-
-
Save lemmensaxel/72ece5cd00026cc05888701d7d65fbe0 to your computer and use it in GitHub Desktop.
React-native expo + keycloak PKCE flow implemented using expo AuthSession
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
); | |
} |
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. π π π―
Hi @SHUHAIB-T, a screenshots of my client configuration:
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
}
}
thank you @lemmensaxel
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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!