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> | |
); | |
} |
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
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. π π π―