Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 31 additions & 14 deletions config/oidc_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,27 @@ func getMetadata() {
}
oidcMetadata = metadata

// We don't check if the endpoint(s) are set. Just overwrite to ensure
// our default values are not being used if the issuer is not CILogon
if err := param.Set("OIDC.DeviceAuthEndpoint", metadata.DeviceAuthURL); err != nil {
log.WithError(err).Warn("Failed to set OIDC.DeviceAuthEndpoint from issuer metadata")
// Only set endpoints from metadata if they were not explicitly configured by the user.
// This allows users to override discovery with explicit endpoint URLs (e.g., for GitHub OAuth2)
if !param.OIDC_DeviceAuthEndpoint.IsSet() {
if err := param.Set("OIDC.DeviceAuthEndpoint", metadata.DeviceAuthURL); err != nil {
log.WithError(err).Warn("Failed to set OIDC.DeviceAuthEndpoint from issuer metadata")
}
}
if err := param.Set("OIDC.TokenEndpoint", metadata.TokenURL); err != nil {
log.WithError(err).Warn("Failed to set OIDC.TokenEndpoint from issuer metadata")
if !param.OIDC_TokenEndpoint.IsSet() {
if err := param.Set("OIDC.TokenEndpoint", metadata.TokenURL); err != nil {
log.WithError(err).Warn("Failed to set OIDC.TokenEndpoint from issuer metadata")
}
}
if err := param.Set("OIDC.UserInfoEndpoint", metadata.UserInfoURL); err != nil {
log.WithError(err).Warn("Failed to set OIDC.UserInfoEndpoint from issuer metadata")
if !param.OIDC_UserInfoEndpoint.IsSet() {
if err := param.Set("OIDC.UserInfoEndpoint", metadata.UserInfoURL); err != nil {
log.WithError(err).Warn("Failed to set OIDC.UserInfoEndpoint from issuer metadata")
}
}
if err := param.Set("OIDC.AuthorizationEndpoint", metadata.AuthURL); err != nil {
log.WithError(err).Warn("Failed to set OIDC.AuthorizationEndpoint from issuer metadata")
if !param.OIDC_AuthorizationEndpoint.IsSet() {
if err := param.Set("OIDC.AuthorizationEndpoint", metadata.AuthURL); err != nil {
log.WithError(err).Warn("Failed to set OIDC.AuthorizationEndpoint from issuer metadata")
}
}
}

Expand Down Expand Up @@ -170,12 +178,21 @@ func GetOIDCAuthorizationEndpoint() (result string, err error) {

func GetOIDCSupportedScopes() (results []string, err error) {
onceMetadata.Do(getMetadata)
err = metadataError
if err != nil {
// First check if we have scopes from OIDC discovery metadata
if metadataError == nil && len(oidcMetadata.ScopesSupported) > 0 {
results = make([]string, len(oidcMetadata.ScopesSupported))
copy(results, oidcMetadata.ScopesSupported)
return
}
// Fall back to explicitly configured OIDC.Scopes (e.g., for OAuth2-only providers like GitHub)
configuredScopes := param.OIDC_Scopes.GetStringSlice()
if len(configuredScopes) > 0 {
results = make([]string, len(configuredScopes))
copy(results, configuredScopes)
return
}
results = make([]string, len(oidcMetadata.ScopesSupported))
copy(results, oidcMetadata.ScopesSupported)
// If neither metadata nor explicit config, return the metadata error
err = metadataError
return
}

Expand Down
56 changes: 56 additions & 0 deletions config/oidc_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
package config

import (
"sync"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -85,3 +86,58 @@ func TestGetOIDCProvider(t *testing.T) {
assert.Equal(t, Globus, get)
})
}

func TestGetMetadataRespectsExplicitEndpoints(t *testing.T) {
t.Cleanup(func() {
ResetConfig()
// Note: Resetting sync.Once in tests is generally not recommended due to potential race conditions.
// However, in this case, we run tests sequentially and need to re-trigger metadata discovery.
// In production code, sync.Once ensures getMetadata() is only called once per process lifetime.
onceMetadata = sync.Once{}
metadataError = nil
oidcMetadata = nil
})

t.Run("explicit-endpoints-not-overridden-by-issuer", func(t *testing.T) {
ResetConfig()
// Reset the sync.Once so we can test getMetadata again in this isolated test
onceMetadata = sync.Once{}
metadataError = nil
oidcMetadata = nil

// Set explicit endpoints (e.g., for GitHub OAuth2)
explicitAuthEndpoint := "https://github.com/login/oauth/authorize"
explicitTokenEndpoint := "https://github.com/login/oauth/access_token"
explicitUserInfoEndpoint := "https://api.github.com/user"
explicitDeviceAuthEndpoint := "https://github.com/login/device/code"

require.NoError(t, param.Set(param.OIDC_AuthorizationEndpoint.GetName(), explicitAuthEndpoint))
require.NoError(t, param.Set(param.OIDC_TokenEndpoint.GetName(), explicitTokenEndpoint))
require.NoError(t, param.Set(param.OIDC_UserInfoEndpoint.GetName(), explicitUserInfoEndpoint))
require.NoError(t, param.Set(param.OIDC_DeviceAuthEndpoint.GetName(), explicitDeviceAuthEndpoint))

// Set OIDC.Issuer to CILogon (which has OIDC discovery)
// This should NOT override the explicitly set endpoints
require.NoError(t, param.Set(param.OIDC_Issuer.GetName(), "https://cilogon.org"))

// Call the metadata discovery - it will try to fetch from CILogon but should not override
onceMetadata.Do(getMetadata)

// Verify the endpoints are still what we set explicitly, not CILogon's
authEndpoint, err := GetOIDCAuthorizationEndpoint()
require.NoError(t, err)
assert.Equal(t, explicitAuthEndpoint, authEndpoint, "Authorization endpoint should not be overridden")

tokenEndpoint, err := GetOIDCTokenEndpoint()
require.NoError(t, err)
assert.Equal(t, explicitTokenEndpoint, tokenEndpoint, "Token endpoint should not be overridden")

userInfoEndpoint, err := GetOIDCUserInfoEndpoint()
require.NoError(t, err)
assert.Equal(t, explicitUserInfoEndpoint, userInfoEndpoint, "UserInfo endpoint should not be overridden")

deviceAuthEndpoint, err := GetOIDCDeviceAuthEndpoint()
require.NoError(t, err)
assert.Equal(t, explicitDeviceAuthEndpoint, deviceAuthEndpoint, "DeviceAuth endpoint should not be overridden")
})
}
73 changes: 73 additions & 0 deletions docs/github-oauth-config-example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Example configuration addition for using GitHub OAuth2 with Pelican
#
# GitHub uses OAuth2 (not OIDC), so you need to explicitly configure
# the endpoints and claim names. This example shows how to set up
# Pelican to authenticate users via GitHub.
#
# Prerequisites:
# 1. Create a GitHub OAuth App at https://github.com/settings/developers
# 2. Set the callback URL to: https://your-server.example.com/api/v1.0/auth/oauth/callback
# 3. Note your Client ID and Client Secret

OIDC:
# Set the issuer to GitHub (used as fallback for issuer claim)
Issuer: https://github.com

# Explicitly configure OAuth2 endpoints (GitHub doesn't support OIDC discovery)
AuthorizationEndpoint: https://github.com/login/oauth/authorize
TokenEndpoint: https://github.com/login/oauth/access_token
UserInfoEndpoint: https://api.github.com/user
# GitHub supports device auth; this endpoint is required for `Origin.EnableIssuer: true`
DeviceAuthEndpoint: https://github.com/login/device/code

# Your GitHub OAuth App credentials
ClientID: your-github-client-id
ClientSecretFile: /path/to/client-secret-file

# GitHub-specific scopes (adjust as needed)
Scopes:
- user
- read:org

# In dev (containers/tunnels), set this to the hostname the OAuth provider will redirect to (e.g., localhost:port)
ClientRedirectHostname: <hostname-the-provider-uses-for-callback>

Issuer:
# Configure which claims to use from GitHub's user info response
# GitHub returns "login" for username instead of the OIDC standard "sub"
OIDCAuthenticationUserClaim: login

# GitHub returns "id" (numeric) instead of the OIDC standard "sub"
OIDCSubjectClaim: id

# GitHub doesn't return an "iss" claim, so this setting tells Pelican
# to fall back to OIDC.Issuer (configured above)
OIDCIssuerClaim: iss

# Use GitHub organization membership as groups
# Requires the "read:org" scope (configured above)
GroupSource: github

# Optional: Require users to be a member of at least one of these GitHub orgs
# If not specified, any authenticated GitHub user can access the server
GroupRequirements:
- my-github-org
- another-allowed-org

# Define authorization based on GitHub organization membership
# This example grants read access to members of specific organizations
AuthorizationTemplates:
- actions: ["read", "create", "modify"]
prefix: /projects/foo
groups: ["my-github-org"]
# Members of "my-github-org" can read/create/modify objects in /projects/foo


Server:
ExternalWebUrl: https://your-server.example.com
# If you want to have the admin access to web UI after logging in via GitHub, set:
UIAdminUsers: ["<YOUR-GITHUB-USERNAME>"]

Origin:
EnableIssuer: true
EnableOIDC: true
28 changes: 28 additions & 0 deletions docs/parameters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2738,6 +2738,28 @@ type: string
default: sub
components: ["origin"]
---
name: Issuer.OIDCSubjectClaim
description: |+
The claim to be used as the unique subject identifier for the user.
For OIDC providers, this is typically "sub".
For OAuth2 providers like GitHub, this might be "id".

If the claim is not found in the user info response, the system will fall back to using the username.
If the claim value is numeric, it will be converted to a string.
type: string
default: sub
components: ["origin", "registry", "cache", "director"]
---
name: Issuer.OIDCIssuerClaim
description: |+
The claim to be used to identify the authentication provider (issuer).
For OIDC providers, this is typically "iss".
For OAuth2 providers that don't provide an issuer claim, the system will fall back to using
the value of OIDC.Issuer or the hostname from OIDC.AuthorizationEndpoint.
type: string
default: iss
components: ["origin", "registry", "cache", "director"]
---
name: Issuer.UserStripDomain
description: |+
Some OIDC issuers generate a username of the form user@domain (such as `[email protected]`); when
Expand All @@ -2759,6 +2781,8 @@ description: |+
the value of the claim specified by `Issuer.OIDCGroupClaim` (defaults to "groups") as a list of groups.
The value may either be a comma-separated string or an array of strings.
- `internal`: Take group information from the Pelican server's internal user database.
- `github`: Fetch group information from GitHub organization. Each GitHub organization the user
belongs to becomes a group. Requires the OAuth2 application to have the `read:org` scope.
type: string
default: none
components: ["origin"]
Expand Down Expand Up @@ -2950,8 +2974,12 @@ description: |+
If the OIDC auto-discovery failed, Pelican will fall back to use individual endpoints set in the configuration. For
any unset endpoints, Pelican will use default values, which are from CILogon.

**Note**: If you explicitly set the OIDC endpoints (AuthorizationEndpoint, TokenEndpoint, etc.), those values will
take precedence over auto-discovery. This is useful for OAuth2 providers like GitHub that don't support OIDC discovery.

For CILogon, it's https://cilogon.org
For Globus, it's https://auth.globus.org
For GitHub OAuth2, set this to https://github.com and explicitly configure the individual endpoints
type: url
default: https://cilogon.org
components: ["registry", "origin", "cache", "director"]
Expand Down
6 changes: 5 additions & 1 deletion oa4mp/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,11 @@ func ConfigureOA4MP() (launcher daemon.Launcher, err error) {

oidcAuthnUserClaim := param.Issuer_OIDCAuthenticationUserClaim.GetString()
groupSource := param.Issuer_GroupSource.GetString()
if groupSource != "" && groupSource != "none" && groupSource != web_ui.GroupSourceTypeOIDC && groupSource != web_ui.GroupSourceTypeFile && groupSource != web_ui.GroupSourceTypeInternal {
if groupSource != "" && groupSource != "none" &&
groupSource != web_ui.GroupSourceTypeOIDC &&
groupSource != web_ui.GroupSourceTypeGitHub &&
groupSource != web_ui.GroupSourceTypeFile &&
groupSource != web_ui.GroupSourceTypeInternal {
err = errors.New("invalid group source: " + groupSource)
return
}
Expand Down
10 changes: 10 additions & 0 deletions param/parameters.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions param/parameters_struct.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading