Skip to content

Commit b5ce48d

Browse files
committed
fix(okta): use ID token to expose app-level group claims to backend
Okta app-level group claims (configured via Sign On → Group claim filter) are embedded in the ID token only — they do not appear in the access token or the /v1/userinfo response. Passing the ID token to the Keep API allows the backend to read the `groups` claim directly from the JWT payload and resolve the correct role via OKTA_*_GROUPS mappings. Changes: - auth.config.ts: use account.id_token instead of account.access_token for Okta; refresh flow also returns the new id_token; remove non-standard `groups` scope from the authorization request - okta_authverifier.py: skip userinfo call when `groups` is already present in the JWT (ID tokens cannot be used as bearer for the userinfo endpoint); downgrade userinfo log to debug to reduce PII exposure
1 parent 7734f31 commit b5ce48d

2 files changed

Lines changed: 22 additions & 7 deletions

File tree

keep-ui/auth.config.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,14 @@ async function refreshAccessToken(token: any) {
106106
);
107107
}
108108

109+
// For Okta, prefer id_token so the backend can read app-level claims (groups).
110+
const newAccessToken =
111+
authType === AuthType.OKTA
112+
? (refreshedTokens.id_token ?? refreshedTokens.access_token)
113+
: refreshedTokens.access_token;
109114
return {
110115
...token,
111-
accessToken: refreshedTokens.access_token,
116+
accessToken: newAccessToken,
112117
accessTokenExpires: Date.now() + (refreshedTokens.expires_in || 3600) * 1000,
113118
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
114119
};
@@ -258,7 +263,6 @@ const baseProviderConfigs = {
258263
clientId: process.env.OKTA_CLIENT_ID!,
259264
clientSecret: process.env.OKTA_CLIENT_SECRET!,
260265
issuer: process.env.OKTA_ISSUER!,
261-
authorization: { params: { scope: "openid email profile groups" } },
262266
}),
263267
],
264268
[AuthType.ONELOGIN]: [
@@ -363,7 +367,10 @@ export const config = {
363367
accessToken = account.access_token;
364368
} else if (authType === AuthType.OKTA) {
365369
tenantId = (profile as any).keep_tenant_id || "keep";
366-
accessToken = account.access_token;
370+
// Use the ID token so the backend can see app-level claims (e.g. groups).
371+
// App-level Okta group claims are embedded in the ID token only, not the
372+
// access token or userinfo response.
373+
accessToken = account.id_token ?? account.access_token;
367374
// Explicit claim takes priority
368375
role = (profile as any).keep_role || (profile as any).role;
369376
// If no explicit claim, resolve from groups via OKTA_*_GROUPS mappings

keep/identitymanager/identity_managers/okta/okta_authverifier.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ def _get_userinfo(self, token: str) -> dict:
8282
timeout=5,
8383
)
8484
if resp.status_code == 200:
85-
return resp.json()
85+
data = resp.json()
86+
logger.debug(f"Userinfo response keys: {list(data.keys())}")
87+
return data
8688
logger.warning(f"Userinfo endpoint returned {resp.status_code}")
8789
except Exception:
8890
logger.exception("Failed to call userinfo endpoint")
@@ -106,8 +108,14 @@ def _verify_bearer_token(self, token: str = Depends(oauth2_scheme)) -> Authentic
106108
options={"verify_exp": True}
107109
)
108110

109-
# Enrich with userinfo claims (groups, name, etc. may not be in the access token)
110-
userinfo = self._get_userinfo(token)
111+
# Only call userinfo when the groups claim is absent from the token.
112+
# When the frontend sends the Okta ID token (which carries app-level
113+
# group claims directly), calling userinfo with it would return 401
114+
# because the userinfo endpoint expects an access token, not an ID token.
115+
if "groups" not in payload:
116+
userinfo = self._get_userinfo(token)
117+
else:
118+
userinfo = {}
111119

112120
tenant_id = payload.get("keep_tenant_id", "keep")
113121
email = (
@@ -121,7 +129,7 @@ def _verify_bearer_token(self, token: str = Depends(oauth2_scheme)) -> Authentic
121129
)
122130
groups = userinfo.get("groups", []) or payload.get("groups", [])
123131

124-
logger.info(f"Token claims — email={email}, name={name}, groups={groups}, group_mappings={self.group_mappings}")
132+
logger.info(f"Token claims — email={email}, groups={groups}, group_mappings={self.group_mappings}")
125133

126134
# Explicit claim overrides always take priority
127135
role_name = payload.get("keep_role") or payload.get("role")

0 commit comments

Comments
 (0)