mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 12:58:02 +08:00
feat: allow claim mapping for user name with oidc (#3540)
* feat: allow claim mapping for user name with oidc * feat: bats test for claim mapping * test: fix dex config in openid mapping test Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * test: add panva idp Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * fix: address copilot comments Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> --------- Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> Co-authored-by: Sky Moore <i@msky.me>
This commit is contained in:
committed by
GitHub
parent
7fa53f5b0f
commit
64829f9502
@@ -28,7 +28,8 @@ jobs:
|
||||
go mod download
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgpgme-dev libassuan-dev libbtrfs-dev \
|
||||
libdevmapper-dev pkg-config rpm uidmap haproxy jq valkey-tools whois
|
||||
libdevmapper-dev pkg-config rpm uidmap haproxy jq valkey-tools whois \
|
||||
npm
|
||||
# install skopeo
|
||||
git clone -b v1.12.0 https://github.com/containers/skopeo.git
|
||||
cd skopeo
|
||||
@@ -55,14 +56,16 @@ jobs:
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
# install nodejs deps (for oidc claim mapping support)
|
||||
sudo npm install -g oidc-provider express
|
||||
# install dex
|
||||
git clone https://github.com/dexidp/dex.git
|
||||
cd dex/
|
||||
git checkout v2.39.1
|
||||
make bin/dex
|
||||
./bin/dex serve $GITHUB_WORKSPACE/test/dex/config-dev.yaml &
|
||||
cd $GITHUB_WORKSPACE
|
||||
# Prepare for stacker run on Ubuntu 24
|
||||
cd $GITHUB_WORKSPACE
|
||||
sudo ./scripts/enable_userns.sh
|
||||
- name: Run CI tests
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"distSpecVersion": "1.1.1",
|
||||
"storage": {
|
||||
"rootDirectory": "/tmp/zot",
|
||||
"dedupe": true
|
||||
},
|
||||
"http": {
|
||||
"address": "127.0.0.1",
|
||||
"port": "8080",
|
||||
"externalUrl": "http://127.0.0.1:8080",
|
||||
"realm": "zot",
|
||||
"auth": {
|
||||
"sessionKeysFile": "examples/sessionKeys.json",
|
||||
"openid": {
|
||||
"providers": {
|
||||
"oidc": {
|
||||
"name": "Zitadel",
|
||||
"issuer": "https://iam.example.com",
|
||||
"credentialsFile": "examples/config-openid-oidc-credentials.json",
|
||||
"scopes": ["openid", "profile", "email", "groups"],
|
||||
"claimMapping": {
|
||||
"username": "preferred_username"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"failDelay": 5
|
||||
},
|
||||
"accessControl": {
|
||||
"repositories": {
|
||||
"**": {
|
||||
"policies": [
|
||||
{
|
||||
"users": [
|
||||
"admin"
|
||||
],
|
||||
"actions": [
|
||||
"read",
|
||||
"create",
|
||||
"update",
|
||||
"delete"
|
||||
]
|
||||
}
|
||||
],
|
||||
"defaultPolicy": ["read"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"log": {
|
||||
"level": "debug"
|
||||
},
|
||||
"extensions": {}
|
||||
}
|
||||
+1
-1
@@ -567,7 +567,7 @@ func (rh *RouteHandler) AuthURLHandler() http.HandlerFunc {
|
||||
callback ui where we will redirect after openid/oauth2 logic is completed*/
|
||||
session, _ := rh.c.CookieStore.Get(r, "statecookie")
|
||||
|
||||
session.Options.Secure = true
|
||||
session.Options.Secure = rh.c.Config.UseSecureSession()
|
||||
session.Options.HttpOnly = true
|
||||
session.Options.SameSite = http.SameSiteDefaultMode
|
||||
session.Options.Path = constants.CallbackBasePath
|
||||
|
||||
@@ -170,6 +170,16 @@ type OpenIDProviderConfig struct {
|
||||
AuthURL string
|
||||
TokenURL string
|
||||
Scopes []string
|
||||
ClaimMapping *ClaimMapping `mapstructure:",omitempty"`
|
||||
}
|
||||
|
||||
// ClaimMapping specifies how OpenID claims are mapped to application fields.
|
||||
// It allows customization of which claim is used as the username when authenticating users.
|
||||
type ClaimMapping struct {
|
||||
// Username specifies which OpenID claim to use as the username for the authenticated user.
|
||||
// Acceptable values include "preferred_username", "email", "sub", "name", or any custom claim name.
|
||||
// If not configured, the default is "email".
|
||||
Username string `mapstructure:"username,omitempty"`
|
||||
}
|
||||
|
||||
type MethodRatelimitConfig struct {
|
||||
@@ -611,6 +621,7 @@ func (c *Config) Sanitize() *Config {
|
||||
AuthURL: config.AuthURL,
|
||||
TokenURL: config.TokenURL,
|
||||
Scopes: config.Scopes,
|
||||
ClaimMapping: config.ClaimMapping,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-8
@@ -90,7 +90,7 @@ func (rh *RouteHandler) SetupRoutes() {
|
||||
rp.CodeExchangeHandler(rh.GithubCodeExchangeCallback(), relyingParty))
|
||||
} else if config.IsOpenIDSupported(provider) {
|
||||
rh.c.Router.HandleFunc(constants.CallbackBasePath+"/"+provider,
|
||||
rp.CodeExchangeHandler(rp.UserinfoCallback(rh.OpenIDCodeExchangeCallback()), relyingParty))
|
||||
rp.CodeExchangeHandler(rp.UserinfoCallback(rh.OpenIDCodeExchangeCallbackWithProvider(provider)), relyingParty))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1998,17 +1998,73 @@ func (rh *RouteHandler) GithubCodeExchangeCallback() rp.CodeExchangeCallback[*oi
|
||||
}
|
||||
}
|
||||
|
||||
// Openid CodeExchange callback.
|
||||
// Openid CodeExchange callback (legacy, kept for compatibility).
|
||||
func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCallback[
|
||||
*oidc.IDTokenClaims,
|
||||
*oidc.UserInfo,
|
||||
] {
|
||||
return rh.OpenIDCodeExchangeCallbackWithProvider("")
|
||||
}
|
||||
|
||||
// OpenIDCodeExchangeCallbackWithProvider is the OIDC CodeExchange callback that supports configurable claim mapping.
|
||||
// The providerName parameter is used to lookup provider-specific claim mapping configuration.
|
||||
// This differs from the legacy version by allowing per-provider claim mapping based on the providerName.
|
||||
func (rh *RouteHandler) OpenIDCodeExchangeCallbackWithProvider(providerName string) rp.CodeExchangeUserinfoCallback[
|
||||
*oidc.IDTokenClaims,
|
||||
*oidc.UserInfo,
|
||||
] {
|
||||
return func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string,
|
||||
relyingParty rp.RelyingParty, info *oidc.UserInfo,
|
||||
) {
|
||||
email := info.UserInfoEmail.Email
|
||||
if email == "" {
|
||||
rh.c.Log.Error().Msg("failed to set user record for empty email value")
|
||||
// Extract username based on claim mapping configuration
|
||||
var username string
|
||||
authConfig := rh.c.Config.CopyAuthConfig()
|
||||
|
||||
if authConfig != nil && authConfig.OpenID != nil && providerName != "" {
|
||||
if providerConfig, ok := authConfig.OpenID.Providers[providerName]; ok {
|
||||
// Check if claim mapping is configured
|
||||
if providerConfig.ClaimMapping != nil && providerConfig.ClaimMapping.Username != "" {
|
||||
claimName := providerConfig.ClaimMapping.Username
|
||||
|
||||
// Use the configured claim
|
||||
switch claimName {
|
||||
case "preferred_username":
|
||||
username = info.PreferredUsername
|
||||
case "email":
|
||||
username = info.UserInfoEmail.Email
|
||||
case "sub":
|
||||
username = info.Subject
|
||||
case "name":
|
||||
username = info.Name
|
||||
default:
|
||||
// Try to get from custom claims in UserInfo
|
||||
if val, ok := info.Claims[claimName].(string); ok {
|
||||
username = val
|
||||
}
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
rh.c.Log.Debug().
|
||||
Str("provider", providerName).
|
||||
Str("claim", claimName).
|
||||
Str("username", username).
|
||||
Msg("extracted username from configured claim")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to email if no username was extracted
|
||||
if username == "" {
|
||||
username = info.UserInfoEmail.Email
|
||||
rh.c.Log.Debug().
|
||||
Str("provider", providerName).
|
||||
Str("username", username).
|
||||
Msg("using email as username (fallback)")
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
rh.c.Log.Error().Msg("failed to set user record for empty username value")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
@@ -2018,7 +2074,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall
|
||||
|
||||
val, ok := info.Claims["groups"].([]interface{})
|
||||
if !ok {
|
||||
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in UserInfo", email)
|
||||
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in UserInfo", username)
|
||||
}
|
||||
|
||||
for _, group := range val {
|
||||
@@ -2027,7 +2083,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall
|
||||
|
||||
val, ok = tokens.IDTokenClaims.Claims["groups"].([]interface{})
|
||||
if !ok {
|
||||
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in IDTokenClaimsToken", email)
|
||||
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in IDTokenClaimsToken", username)
|
||||
}
|
||||
|
||||
for _, group := range val {
|
||||
@@ -2037,7 +2093,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall
|
||||
slices.Sort(groups)
|
||||
groups = slices.Compact(groups)
|
||||
|
||||
callbackUI, err := OAuth2Callback(rh.c, w, r, state, email, groups)
|
||||
callbackUI, err := OAuth2Callback(rh.c, w, r, state, username, groups)
|
||||
if err != nil {
|
||||
if errors.Is(err, zerr.ErrInvalidStateCookie) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ tests=("pushpull" "pushpull_authn" "delete_images" "referrers" "metadata" "anony
|
||||
"annotations" "detect_manifest_collision" "cve" "sync" "sync_docker" "sync_replica_cluster"
|
||||
"scrub" "garbage_collect" "metrics" "metrics_minimal" "multiarch_index" "docker_compat" "redis_local" "redis_session_store"
|
||||
"events_nats" "events_http" "events_nats_lint_failure" "events_http_lint_failure" "events_sink_failure" "events_config_decoding"
|
||||
"fips140" "fips140_authn")
|
||||
"fips140" "fips140_authn" "openid_claim_mapping")
|
||||
|
||||
for test in ${tests[*]}; do
|
||||
${BATS} ${BATS_FLAGS} ${SCRIPTPATH}/${test}.bats > ${test}.log & pids+=($!)
|
||||
|
||||
@@ -65,6 +65,6 @@ function zb_run() {
|
||||
}
|
||||
|
||||
function log_output() {
|
||||
local zot_log_file=${BATS_FILE_TMPDIR}/zot/zot-log.json
|
||||
local zot_log_file=${1:-${BATS_FILE_TMPDIR}/zot/zot-log.json}
|
||||
cat ${zot_log_file} | jq ' .["message"] '
|
||||
}
|
||||
|
||||
Executable
+399
@@ -0,0 +1,399 @@
|
||||
# Note: Intended to be run as "make run-blackbox-tests" or "make run-blackbox-ci"
|
||||
# Makefile target installs & checks all necessary tooling
|
||||
# Extra tools that are not covered in Makefile target needs to be added in verify_prerequisites()
|
||||
|
||||
load helpers_zot
|
||||
load helpers_wait
|
||||
load ../port_helper
|
||||
|
||||
function verify_prerequisites {
|
||||
if [ ! $(command -v curl) ]; then
|
||||
echo "you need to install curl as a prerequisite to running the tests" >&3
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! $(command -v jq) ]; then
|
||||
echo "you need to install jq as a prerequisite to running the tests" >&3
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! $(command -v docker) ]; then
|
||||
echo "you need to install docker as a prerequisite to running the tests" >&3
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
IDP_PID=""
|
||||
|
||||
function setup_file() {
|
||||
# Verify prerequisites are available
|
||||
if ! $(verify_prerequisites); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pre-allocate all ports we'll need
|
||||
idp_port=$(get_free_port_for_service "idp")
|
||||
echo ${idp_port} > ${BATS_FILE_TMPDIR}/idp.port
|
||||
|
||||
# Setup oidc provider
|
||||
npm link oidc-provider express
|
||||
node ${ROOT_DIR}/test/scripts/panva-idp.js ${idp_port} > ${BATS_FILE_TMPDIR}/idp.log 2>&1 &
|
||||
IDP_PID=$!
|
||||
|
||||
# Wait for oidc provider to be ready
|
||||
echo "Waiting for idp to be ready on port ${idp_port}..." >&3
|
||||
local idp_url=http://127.0.0.1:${idp_port}/.well-known/openid-configuration
|
||||
local max_attempts=60
|
||||
local attempt=0
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
if curl -f -s -o /dev/null ${idp_url} 2>&1; then
|
||||
echo "oidc idp is ready!" >&3
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo "idp failed to start within timeout" >&3
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function teardown_file() {
|
||||
zot_stop_all
|
||||
kill ${IDP_PID} 2>/dev/null || true
|
||||
if [ -f "${BATS_FILE_TMPDIR}/idp.log" ]; then
|
||||
echo "--- IDP LOG ---"
|
||||
cat ${BATS_FILE_TMPDIR}/idp.log
|
||||
echo "--- END IDP LOG ---"
|
||||
fi
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
# conditionally printing on failure is possible from teardown but not from teardown_file
|
||||
if [ -f "${BATS_FILE_TMPDIR}/zot-preferred-username.log" ]; then
|
||||
cat ${BATS_FILE_TMPDIR}/zot-preferred-username.log
|
||||
fi
|
||||
if [ -f "${BATS_FILE_TMPDIR}/zot-email.log" ]; then
|
||||
cat ${BATS_FILE_TMPDIR}/zot-email.log
|
||||
fi
|
||||
if [ -f "${BATS_FILE_TMPDIR}/zot-sub.log" ]; then
|
||||
cat ${BATS_FILE_TMPDIR}/zot-sub.log
|
||||
fi
|
||||
if [ -f "${BATS_FILE_TMPDIR}/zot-default.log" ]; then
|
||||
cat ${BATS_FILE_TMPDIR}/zot-default.log
|
||||
fi
|
||||
if [ -f "${BATS_FILE_TMPDIR}/idp.log" ]; then
|
||||
echo "--- IDP LOG (teardown) ---"
|
||||
cat ${BATS_FILE_TMPDIR}/idp.log
|
||||
echo "--- END IDP LOG ---"
|
||||
fi
|
||||
}
|
||||
|
||||
function idp_session() {
|
||||
local zot_port=${1}
|
||||
local idp_port=$(cat ${BATS_FILE_TMPDIR}/idp.port)
|
||||
|
||||
# With the auto-login enabled IDP, we just need to hit the login endpoint and follow redirects.
|
||||
# We use a cookie jar to handle the session cookies during the redirects.
|
||||
curl -v -L -c ${BATS_FILE_TMPDIR}/cookies "http://127.0.0.1:${zot_port}/zot/auth/login?provider=oidc"
|
||||
}
|
||||
|
||||
@test "test OIDC claim mapping with preferred_username" {
|
||||
local zot_root_dir=${BATS_FILE_TMPDIR}/zot-preferred-username
|
||||
local zot_config_file=${BATS_FILE_TMPDIR}/zot_config_preferred_username.json
|
||||
local zot_credentials_file=${BATS_FILE_TMPDIR}/zot_credentials_preferred_username.json
|
||||
local idp_port=$(cat ${BATS_FILE_TMPDIR}/idp.port)
|
||||
zot_port=$(get_free_port_for_service "zot_preferred_username")
|
||||
|
||||
mkdir -p ${zot_root_dir}
|
||||
|
||||
cat > ${zot_credentials_file}<<EOF
|
||||
{
|
||||
"clientid": "zot-client",
|
||||
"clientsecret": "ZXhhbXBsZS1hcHAtc2VjcmV0"
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > ${zot_config_file}<<EOF
|
||||
{
|
||||
"distSpecVersion": "1.1.1",
|
||||
"storage": {
|
||||
"rootDirectory": "${zot_root_dir}",
|
||||
"dedupe": true
|
||||
},
|
||||
"http": {
|
||||
"address": "127.0.0.1",
|
||||
"port": "${zot_port}",
|
||||
"realm": "zot",
|
||||
"auth": {
|
||||
"openid": {
|
||||
"providers": {
|
||||
"oidc": {
|
||||
"name": "panva",
|
||||
"issuer": "http://127.0.0.1:${idp_port}/",
|
||||
"credentialsFile": "${zot_credentials_file}",
|
||||
"scopes": ["openid", "profile", "email", "groups"],
|
||||
"claimMapping": {
|
||||
"username": "preferred_username"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"failDelay": 5
|
||||
},
|
||||
"accessControl": {
|
||||
"repositories": {
|
||||
"**": {
|
||||
"policies": [
|
||||
{
|
||||
"users": ["alice"],
|
||||
"actions": ["read", "create", "update", "delete"]
|
||||
}
|
||||
],
|
||||
"defaultPolicy": ["read"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"log": {
|
||||
"level": "debug",
|
||||
"output": "${BATS_FILE_TMPDIR}/zot-preferred-username.log"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
zot_serve ${ZOT_PATH} ${zot_config_file}
|
||||
wait_zot_reachable ${zot_port}
|
||||
|
||||
run idp_session ${zot_port}
|
||||
[ "$status" -eq 0 ]
|
||||
echo "$output"
|
||||
|
||||
# Verify that the log contains the preferred_username claim being used
|
||||
log_output ${BATS_FILE_TMPDIR}/zot-preferred-username.log | jq 'contains("extracted username from configured claim")' | grep true
|
||||
! log_output ${BATS_FILE_TMPDIR}/zot-preferred-username.log | grep -q "using email as username (fallback)"
|
||||
}
|
||||
|
||||
@test "test OIDC claim mapping with email" {
|
||||
local zot_root_dir=${BATS_FILE_TMPDIR}/zot-email
|
||||
local zot_config_file=${BATS_FILE_TMPDIR}/zot_config_email.json
|
||||
local zot_credentials_file=${BATS_FILE_TMPDIR}/zot_credentials_email.json
|
||||
local idp_port=$(cat ${BATS_FILE_TMPDIR}/idp.port)
|
||||
zot_port=$(get_free_port_for_service "zot_email")
|
||||
|
||||
mkdir -p ${zot_root_dir}
|
||||
|
||||
cat > ${zot_credentials_file}<<EOF
|
||||
{
|
||||
"clientid": "zot-client",
|
||||
"clientsecret": "ZXhhbXBsZS1hcHAtc2VjcmV0"
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > ${zot_config_file}<<EOF
|
||||
{
|
||||
"distSpecVersion": "1.1.1",
|
||||
"storage": {
|
||||
"rootDirectory": "${zot_root_dir}",
|
||||
"dedupe": true
|
||||
},
|
||||
"http": {
|
||||
"address": "127.0.0.1",
|
||||
"port": "${zot_port}",
|
||||
"realm": "zot",
|
||||
"auth": {
|
||||
"openid": {
|
||||
"providers": {
|
||||
"oidc": {
|
||||
"name": "panva",
|
||||
"issuer": "http://127.0.0.1:${idp_port}/",
|
||||
"credentialsFile": "${zot_credentials_file}",
|
||||
"scopes": ["openid", "profile", "email", "groups"],
|
||||
"claimMapping": {
|
||||
"username": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"failDelay": 5
|
||||
},
|
||||
"accessControl": {
|
||||
"repositories": {
|
||||
"**": {
|
||||
"policies": [
|
||||
{
|
||||
"users": ["alice@example.com"],
|
||||
"actions": ["read", "create", "update", "delete"]
|
||||
}
|
||||
],
|
||||
"defaultPolicy": ["read"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"log": {
|
||||
"level": "debug",
|
||||
"output": "${BATS_FILE_TMPDIR}/zot-email.log"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
zot_serve ${ZOT_PATH} ${zot_config_file}
|
||||
wait_zot_reachable ${zot_port}
|
||||
|
||||
run idp_session ${zot_port}
|
||||
[ "$status" -eq 0 ]
|
||||
echo "$output"
|
||||
|
||||
# Verify that the log contains email claim being used
|
||||
grep -q "extracted username from configured claim" ${BATS_FILE_TMPDIR}/zot-email.log
|
||||
! grep -q "using email as username (fallback)" ${BATS_FILE_TMPDIR}/zot-email.log
|
||||
}
|
||||
|
||||
@test "test OIDC claim mapping with sub" {
|
||||
local zot_root_dir=${BATS_FILE_TMPDIR}/zot-sub
|
||||
local zot_config_file=${BATS_FILE_TMPDIR}/zot_config_sub.json
|
||||
local zot_credentials_file=${BATS_FILE_TMPDIR}/zot_credentials_sub.json
|
||||
local idp_port=$(cat ${BATS_FILE_TMPDIR}/idp.port)
|
||||
zot_port=$(get_free_port_for_service "zot_sub")
|
||||
|
||||
mkdir -p ${zot_root_dir}
|
||||
|
||||
cat > ${zot_credentials_file}<<EOF
|
||||
{
|
||||
"clientid": "zot-client",
|
||||
"clientsecret": "ZXhhbXBsZS1hcHAtc2VjcmV0"
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > ${zot_config_file}<<EOF
|
||||
{
|
||||
"distSpecVersion": "1.1.1",
|
||||
"storage": {
|
||||
"rootDirectory": "${zot_root_dir}",
|
||||
"dedupe": true
|
||||
},
|
||||
"http": {
|
||||
"address": "127.0.0.1",
|
||||
"port": "${zot_port}",
|
||||
"realm": "zot",
|
||||
"auth": {
|
||||
"openid": {
|
||||
"providers": {
|
||||
"oidc": {
|
||||
"name": "panva",
|
||||
"issuer": "http://127.0.0.1:${idp_port}/",
|
||||
"credentialsFile": "${zot_credentials_file}",
|
||||
"scopes": ["openid", "profile", "email", "groups"],
|
||||
"claimMapping": {
|
||||
"username": "sub"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"failDelay": 5
|
||||
},
|
||||
"accessControl": {
|
||||
"repositories": {
|
||||
"**": {
|
||||
"defaultPolicy": ["read"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"log": {
|
||||
"level": "debug",
|
||||
"output": "${BATS_FILE_TMPDIR}/zot-sub.log"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
zot_serve ${ZOT_PATH} ${zot_config_file}
|
||||
wait_zot_reachable ${zot_port}
|
||||
|
||||
run idp_session ${zot_port}
|
||||
[ "$status" -eq 0 ]
|
||||
echo "$output"
|
||||
|
||||
# Verify that the log contains sub claim being used
|
||||
log_output ${BATS_FILE_TMPDIR}/zot-sub.log | jq 'contains("extracted username from configured claim")' | grep true
|
||||
! log_output ${BATS_FILE_TMPDIR}/zot-sub.log | grep -q "using email as username (fallback)"
|
||||
}
|
||||
|
||||
@test "test OIDC with no claim mapping (default to email)" {
|
||||
local zot_root_dir=${BATS_FILE_TMPDIR}/zot-default
|
||||
local zot_config_file=${BATS_FILE_TMPDIR}/zot_config_default.json
|
||||
ZOT_LOG_FILE=${BATS_FILE_TMPDIR}/zot-default.log
|
||||
local zot_credentials_file=${BATS_FILE_TMPDIR}/zot_credentials_default.json
|
||||
local idp_port=$(cat ${BATS_FILE_TMPDIR}/idp.port)
|
||||
zot_port=$(get_free_port_for_service "zot_default")
|
||||
|
||||
mkdir -p ${zot_root_dir}
|
||||
|
||||
cat > ${zot_credentials_file}<<EOF
|
||||
{
|
||||
"clientid": "zot-client",
|
||||
"clientsecret": "ZXhhbXBsZS1hcHAtc2VjcmV0"
|
||||
}
|
||||
EOF
|
||||
|
||||
# Note: No claimMapping section - should default to email
|
||||
cat > ${zot_config_file}<<EOF
|
||||
{
|
||||
"distSpecVersion": "1.1.1",
|
||||
"storage": {
|
||||
"rootDirectory": "${zot_root_dir}",
|
||||
"dedupe": true
|
||||
},
|
||||
"http": {
|
||||
"address": "127.0.0.1",
|
||||
"port": "${zot_port}",
|
||||
"realm": "zot",
|
||||
"auth": {
|
||||
"openid": {
|
||||
"providers": {
|
||||
"oidc": {
|
||||
"name": "panva",
|
||||
"issuer": "http://127.0.0.1:${idp_port}/",
|
||||
"credentialsFile": "${zot_credentials_file}",
|
||||
"scopes": ["openid", "profile", "email", "groups"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"failDelay": 5
|
||||
},
|
||||
"accessControl": {
|
||||
"repositories": {
|
||||
"**": {
|
||||
"policies": [
|
||||
{
|
||||
"users": ["alice@example.com"],
|
||||
"actions": ["read", "create", "update", "delete"]
|
||||
}
|
||||
],
|
||||
"defaultPolicy": ["read"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"log": {
|
||||
"level": "debug",
|
||||
"output": "${ZOT_LOG_FILE}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
zot_serve ${ZOT_PATH} ${zot_config_file}
|
||||
wait_zot_reachable ${zot_port}
|
||||
|
||||
run idp_session ${zot_port}
|
||||
[ "$status" -eq 0 ]
|
||||
echo "$output"
|
||||
|
||||
# Verify that the log contains email claim being used (default behavior)
|
||||
log_output ${ZOT_LOG_FILE} | jq 'contains("using email as username (fallback)")' | grep true
|
||||
}
|
||||
@@ -414,5 +414,27 @@
|
||||
"begin": 11440,
|
||||
"end": 11449
|
||||
}
|
||||
},
|
||||
"blackbox/openid_claim_mapping.bats": {
|
||||
"idp": {
|
||||
"begin": 11450,
|
||||
"end": 11459
|
||||
},
|
||||
"zot_preferred_username": {
|
||||
"begin": 11460,
|
||||
"end": 11469
|
||||
},
|
||||
"zot_email": {
|
||||
"begin": 11470,
|
||||
"end": 11479
|
||||
},
|
||||
"zot_sub": {
|
||||
"begin": 11480,
|
||||
"end": 11489
|
||||
},
|
||||
"zot_default": {
|
||||
"begin": 11490,
|
||||
"end": 11499
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import Provider from "oidc-provider";
|
||||
import express from "express";
|
||||
|
||||
const port = process.argv[2] ? Number(process.argv[2]) : 5556;
|
||||
const cb_url = process.argv[3] ? process.argv[3] : 'http://127.0.0.1:5555/callback';
|
||||
|
||||
// ---- Simple in-memory user store ----
|
||||
const users = [
|
||||
{
|
||||
id: "1",
|
||||
username: "alice",
|
||||
password: "password",
|
||||
claims: {
|
||||
sub: "1",
|
||||
email: "alice@example.com",
|
||||
email_verified: true,
|
||||
preferred_username: "alice",
|
||||
name: "Alice",
|
||||
groups: ["admin", "dev"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ---- Account adapter for oidc-provider ----
|
||||
const Account = {
|
||||
async findAccount(ctx, id) {
|
||||
const user = users.find((u) => u.id === id);
|
||||
if (!user) return undefined;
|
||||
|
||||
return {
|
||||
accountId: id,
|
||||
async claims(use, scope) {
|
||||
console.log(`[IDP] Account.claims called with use=${use} scope=${scope}`);
|
||||
return {
|
||||
sub: user.claims.sub,
|
||||
email: user.claims.email,
|
||||
email_verified: user.claims.email_verified,
|
||||
preferred_username: user.claims.preferred_username,
|
||||
name: user.claims.name,
|
||||
groups: user.claims.groups,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async authenticate(username, password) {
|
||||
return users.find(
|
||||
(u) => u.username === username && u.password === password
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ---- OIDC provider configuration ----
|
||||
const configuration = {
|
||||
clients: [{
|
||||
client_id: 'zot-client',
|
||||
client_secret: 'ZXhhbXBsZS1hcHAtc2VjcmV0',
|
||||
redirect_uris: [cb_url], // Will be overridden dynamically
|
||||
response_types: ["code"],
|
||||
grant_types: ["authorization_code"],
|
||||
token_endpoint_auth_method: "client_secret_basic",
|
||||
scope: "openid profile email groups",
|
||||
}],
|
||||
|
||||
features: {
|
||||
devInteractions: { enabled: false }, // we provide our own login
|
||||
introspection: { enabled: true },
|
||||
revocation: { enabled: true },
|
||||
resourceIndicators: { enabled: false },
|
||||
// claimsParameter is now automatically supported
|
||||
},
|
||||
|
||||
interactions: {
|
||||
url(ctx, interaction) {
|
||||
return `/login?uid=${interaction.uid}`;
|
||||
},
|
||||
},
|
||||
|
||||
async findAccount(ctx, id) {
|
||||
return Account.findAccount(ctx, id);
|
||||
},
|
||||
|
||||
// Required for ZITADEL: return standard claims
|
||||
scopes: ["openid", "profile", "email", "groups"],
|
||||
claims: {
|
||||
openid: ["sub"],
|
||||
profile: ["name", "preferred_username"],
|
||||
email: ["email", "email_verified"],
|
||||
groups: ["groups"],
|
||||
},
|
||||
|
||||
async renderError(ctx, out, error) {
|
||||
ctx.body = `OIDC Error: ${error.error_description}`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---- Create provider ----
|
||||
const issuer = `http://127.0.0.1:${port}/`;
|
||||
const provider = new Provider(issuer, configuration);
|
||||
|
||||
// Monkey-patch Client prototype to allow dynamic redirect URIs
|
||||
const Client = provider.Client;
|
||||
const originalRedirectUriAllowed = Client.prototype.redirectUriAllowed;
|
||||
|
||||
Client.prototype.redirectUriAllowed = function(value) {
|
||||
// Allow any localhost URI for testing
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.hostname === '127.0.0.1' && parsed.pathname === '/zot/auth/callback/oidc') {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore invalid URLs
|
||||
}
|
||||
return originalRedirectUriAllowed.call(this, value);
|
||||
};
|
||||
|
||||
provider.Client.find('zot-client').then(client => {
|
||||
console.log('Startup check: Client found:', client ? 'yes' : 'no');
|
||||
}).catch(err => console.error('Startup check error:', err));
|
||||
|
||||
// ---- Simple login UI ----
|
||||
const app = express();
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
console.log(`[IDP] Request: ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
app.get("/login", async (req, res) => {
|
||||
console.log("Hit /login endpoint");
|
||||
const { uid } = req.query;
|
||||
const user = users[0];
|
||||
|
||||
const result = await provider.interactionDetails(req, res);
|
||||
console.log("Interaction details:", result);
|
||||
if (result.prompt.details) {
|
||||
console.log("Prompt details:", JSON.stringify(result.prompt.details, null, 2));
|
||||
}
|
||||
|
||||
if (result.prompt.name === 'login') {
|
||||
await provider.interactionFinished(
|
||||
req,
|
||||
res,
|
||||
{
|
||||
login: {
|
||||
accountId: user.id,
|
||||
},
|
||||
},
|
||||
{ mergeWithLastSubmission: false }
|
||||
);
|
||||
} else {
|
||||
// Manually create a grant to ensure scopes are accepted
|
||||
const grant = new provider.Grant({
|
||||
accountId: result.session.accountId,
|
||||
clientId: result.params.client_id,
|
||||
});
|
||||
|
||||
const scopes = result.params.scope.split(' ');
|
||||
console.log(`[IDP] Manually granting scopes: ${scopes.join(' ')}`);
|
||||
grant.addOIDCScope(scopes.join(' '));
|
||||
|
||||
const grantId = await grant.save();
|
||||
|
||||
await provider.interactionFinished(
|
||||
req,
|
||||
res,
|
||||
{ consent: { grantId } },
|
||||
{ mergeWithLastSubmission: true }
|
||||
);
|
||||
}
|
||||
console.log("Interaction finished");
|
||||
});
|
||||
|
||||
// app.post("/login", async (req, res, next) => { ... });
|
||||
|
||||
// ---- mount OIDC provider ----
|
||||
app.use(provider.callback());
|
||||
app.listen(port, () => {
|
||||
console.log(`OIDC Provider listening at ${issuer}`);
|
||||
});
|
||||
Reference in New Issue
Block a user