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:
Ramkumar Chinchani
2025-11-20 08:54:56 -08:00
committed by GitHub
parent 7fa53f5b0f
commit 64829f9502
10 changed files with 740 additions and 13 deletions
+5 -2
View File
@@ -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: |
+54
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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+=($!)
+1 -1
View File
@@ -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"] '
}
+399
View File
@@ -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
}
+22
View File
@@ -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
}
}
}
+182
View File
@@ -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}`);
});