Memberships: groups kept by portal itself, a person by phone number first
Test / test (pull_request) Successful in 58s

A membership is a case in the built-in portal_memberships bucket:
invited by a desk, a grants, the seed or the responsible pass; active
from the person's first sign-in, when the provider's subject, phone
number or address matches the record; removed by a desk, and invited
again after that. One record per group and person however it was
keyed, and at sign-in every record that matched learns what the
provider verified. The active memberships join the groups claim, so
Cedar sees the same parents as before.

PEOPLE_BACKEND picks Kanidm or memberships; unset, a KANIDM_API_TOKEN
means Kanidm. The NATS access service looks a person's groups up when
a question names them by sub, phone or email and lists none. The
sign-in asks for the phoneNumber scope and reads it from userinfo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 13:28:28 +02:00
co-authored by Claude Fable 5.1
parent b9761cec63
commit f047e21c4a
20 changed files with 849 additions and 64 deletions
+32 -3
View File
@@ -11,10 +11,10 @@ use axum::{
response::Redirect,
};
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims},
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
TokenResponse,
OAuth2TokenResponse, TokenResponse,
};
use serde::Deserialize;
use tower_sessions::Session;
@@ -50,6 +50,12 @@ const NONCE_KEY: &str = "oidc_nonce";
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc {
/// Sign-in disabled: what a content-only instance has, and what a
/// test builds an `AppState` with.
pub fn disabled() -> Self {
Self { inner: None }
}
/// Discovers the provider and builds the client from environment:
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
/// With `KANIDM_URL` unset, sign-in is disabled instead of fatal -
@@ -153,6 +159,9 @@ pub async fn login(
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("profile".to_string()))
.add_scope(Scope::new("email".to_string()))
// A provider that verifies phone numbers (Vipps) gives one for
// this scope; Kanidm ignores a scope it does not know.
.add_scope(Scope::new("phoneNumber".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
@@ -242,13 +251,33 @@ pub async fn callback(
let groups = extract_groups_claim(&id_token);
let email = claims.email().map(|e| e.as_str().trim().to_lowercase());
let user = User {
// The verified phone number lives at userinfo, not in the ID token,
// for providers that give one (Vipps). A provider that has none, or
// no userinfo endpoint, leaves it empty; nothing depends on it.
let phone = match oidc.client.user_info(token_response.access_token().clone(), None) {
Ok(request) => request
.request_async(&oidc.http)
.await
.ok()
.and_then(|info: CoreUserInfoClaims| info.phone_number().map(|p| p.as_str().to_string())),
Err(_) => None,
};
let mut user = User {
sub: claims.subject().as_str().to_string(),
username,
display_name,
groups,
phone,
email,
};
// What they belong to, as portal keeps it: every invitation for
// them becomes a membership now, and their active memberships join
// whatever groups the provider itself asserted.
for group in crate::memberships::on_signed_in(&state, &user).await {
if !user.groups.contains(&group) {
user.groups.push(group);
}
}
// Rotate the session id on privilege change, then store the user.
session.cycle_id().await.map_err(internal)?;