feat(webfinger): use webfinger properties instead new relations

This works the previous commits so that clients can add an addtional
'platform' query parameter to the webfinger request that  can be used
to query the oidc client id and list of scopes that the clients need
to use when connecting to the IDP.

This also removes the non-standard issuer relatation introduced in a
previous commit as we can just introduce new relations in the
http://openid.net name space.

For IDP like Authentik that create a separate issuer url per Client
(Application in Authentik's terms) it is suggested to just configure
as single Client and use that id for all platforms (i.e. setting
'WEBFINGER_ANDROID_OIDC_CLIENT_ID', 'WEBFINGER_DESKTOP_OIDC_CLIENT_ID',
'WEBFINGER_IOS_OIDC_CLIENT_ID' and 'WEBFINGER_WEB_OIDC_CLIENT_ID' to
same value.

Related: #2088
Related: https://github.com/opencloud-eu/desktop/issues/246
This commit is contained in:
Ralf Haferkamp
2026-01-29 16:31:23 +01:00
committed by Ralf Haferkamp
parent 24aaeb46ba
commit 4f1aca6d90
13 changed files with 116 additions and 129 deletions

View File

@@ -120,17 +120,7 @@ func getRelationProviders(cfg *config.Config) (map[string]service.RelationProvid
for _, relationURI := range cfg.Relations {
switch relationURI {
case relations.OpenIDConnectRel:
rels[relationURI] = relations.OpenIDDiscovery(cfg.IDP)
case relations.OpenIDConnectDesktopRel:
// Handled below - can also be auto-enabled via DesktopIDP config
if cfg.DesktopIDP != "" {
rels[relationURI] = relations.OpenIDDiscoveryDesktop(cfg.DesktopIDP, cfg.DesktopClientID)
}
case relations.OpenIDConnectMobileRel:
// Handled below - can also be auto-enabled via MobileIDP config
if cfg.MobileIDP != "" {
rels[relationURI] = relations.OpenIDDiscoveryMobile(cfg.MobileIDP, cfg.MobileClientID)
}
rels[relationURI] = relations.OpenIDDiscovery(cfg.IDP, cfg.OIDCClientConfigs)
case relations.OpenCloudInstanceRel:
var err error
rels[relationURI], err = relations.OpenCloudInstance(cfg.Instances, cfg.OpenCloudURL)
@@ -142,22 +132,5 @@ func getRelationProviders(cfg *config.Config) (map[string]service.RelationProvid
}
}
// Auto-enable desktop OIDC issuer when DesktopIDP is configured,
// even if not explicitly listed in Relations. This provides a simpler
// configuration experience - just set WEBFINGER_OIDC_ISSUER_DESKTOP.
// See: https://github.com/opencloud-eu/desktop/issues/246
if cfg.DesktopIDP != "" {
if _, exists := rels[relations.OpenIDConnectDesktopRel]; !exists {
rels[relations.OpenIDConnectDesktopRel] = relations.OpenIDDiscoveryDesktop(cfg.DesktopIDP, cfg.DesktopClientID)
}
}
// Auto-enable mobile OIDC issuer when MobileIDP is configured
if cfg.MobileIDP != "" {
if _, exists := rels[relations.OpenIDConnectMobileRel]; !exists {
rels[relations.OpenIDConnectMobileRel] = relations.OpenIDDiscoveryMobile(cfg.MobileIDP, cfg.MobileClientID)
}
}
return rels, nil
}

View File

@@ -17,15 +17,21 @@ type Config struct {
HTTP HTTP `yaml:"http"`
Instances []Instance `yaml:"instances"`
Relations []string `yaml:"relations" env:"WEBFINGER_RELATIONS" desc:"A list of relation URIs or registered relation types to add to webfinger responses. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
IDP string `yaml:"idp" env:"OC_URL;OC_OIDC_ISSUER;WEBFINGER_OIDC_ISSUER" desc:"The identity provider href for the openid-discovery relation." introductionVersion:"1.0.0"`
DesktopIDP string `yaml:"desktop_idp" env:"WEBFINGER_OIDC_ISSUER_DESKTOP" desc:"The identity provider href for desktop clients. When set, desktop clients will use this issuer instead of the default IDP. This allows configuring separate OIDC clients for web and desktop applications." introductionVersion:"%%NEXT%%"`
DesktopClientID string `yaml:"desktop_client_id" env:"WEBFINGER_OIDC_CLIENT_ID_DESKTOP" desc:"The OIDC client ID for desktop clients. When set along with WEBFINGER_OIDC_ISSUER_DESKTOP, this client ID will be provided to desktop clients via webfinger properties." introductionVersion:"%%NEXT%%"`
MobileIDP string `yaml:"mobile_idp" env:"WEBFINGER_OIDC_ISSUER_MOBILE" desc:"The identity provider href for mobile clients. When set, mobile clients will use this issuer instead of the default IDP. This allows configuring separate OIDC clients for web and mobile applications." introductionVersion:"%%NEXT%%"`
MobileClientID string `yaml:"mobile_client_id" env:"WEBFINGER_OIDC_CLIENT_ID_MOBILE" desc:"The OIDC client ID for mobile clients. When set along with WEBFINGER_OIDC_ISSUER_MOBILE, this client ID will be provided to mobile clients via webfinger properties." introductionVersion:"%%NEXT%%"`
OpenCloudURL string `yaml:"opencloud_url" env:"OC_URL;WEBFINGER_OPENCLOUD_SERVER_INSTANCE_URL" desc:"The URL for the legacy OpenCloud server instance relation (not to be confused with the product OpenCloud Server). It defaults to the OC_URL but can be overridden to support some reverse proxy corner cases. To shard the deployment, multiple instances can be configured in the configuration file." introductionVersion:"1.0.0"`
Insecure bool `yaml:"insecure" env:"OC_INSECURE;WEBFINGER_INSECURE" desc:"Allow insecure connections to the WEBFINGER service." introductionVersion:"1.0.0"`
Instances []Instance `yaml:"instances"`
Relations []string `yaml:"relations" env:"WEBFINGER_RELATIONS" desc:"A list of relation URIs or registered relation types to add to webfinger responses. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
IDP string `yaml:"idp" env:"OC_URL;OC_OIDC_ISSUER;WEBFINGER_OIDC_ISSUER" desc:"The identity provider href for the openid-discovery relation." introductionVersion:"1.0.0"`
AndroidClientID string `yaml:"android_client_id" env:"WEBFINGER_ANDROID_OIDC_CLIENT_ID" desc:"The OIDC client ID for Android app." introductionVersion:"%%NEXT%%"`
AndroidClientScopes []string `yaml:"android_client_scopes" env:"WEBFINGER_ANDROID_OIDC_CLIENT_SCOPES" desc:"The OIDC client scopes the Android app should request." introductionVersion:"%%NEXT%%"`
DesktopClientID string `yaml:"desktop_client_id" env:"WEBFINGER_DESKTOP_OIDC_CLIENT_ID" desc:"The OIDC client ID for the OpenCloud desktop application." introductionVersion:"%%NEXT%%"`
DesktopClientScopes []string `yaml:"desktop_client_scopes" env:"WEBFINGER_DESKTOP_OIDC_CLIENT_SCOPES" desc:"The OIDC client scopes the OpenCloud desktop application should request." introductionVersion:"%%NEXT%%"`
IOSClientID string `yaml:"ios_client_id" env:"WEBFINGER_IOS_OIDC_CLIENT_ID" desc:"The OIDC client ID for the IOS app." introductionVersion:"%%NEXT%%"`
IOSClientScopes []string `yaml:"ios_client_scopes" env:"WEBFINGER_IOS_OIDC_CLIENT_SCOPES" desc:"The OIDC client scopes the IOS app should request." introductionVersion:"%%NEXT%%"`
WebClientID string `yaml:"web_client_id" env:"WEBFINGER_WEB_OIDC_CLIENT_ID" desc:"The OIDC client ID for the OpenCloud web client." introductionVersion:"%%NEXT%%"`
WebClientScopes []string `yaml:"web_client_scopes" env:"WEBFINGER_WEB_OIDC_CLIENT_SCOPES" desc:"The OIDC client scopes the OpenCloud web client should request." introductionVersion:"%%NEXT%%"`
OpenCloudURL string `yaml:"opencloud_url" env:"OC_URL;WEBFINGER_OPENCLOUD_SERVER_INSTANCE_URL" desc:"The URL for the legacy OpenCloud server instance relation (not to be confused with the product OpenCloud Server). It defaults to the OC_URL but can be overridden to support some reverse proxy corner cases. To shard the deployment, multiple instances can be configured in the configuration file." introductionVersion:"1.0.0"`
Insecure bool `yaml:"insecure" env:"OC_INSECURE;WEBFINGER_INSECURE" desc:"Allow insecure connections to the WEBFINGER service." introductionVersion:"1.0.0"`
OIDCClientConfigs map[string]OIDCClientConfig `yaml:"-"`
Context context.Context `yaml:"-"`
}
@@ -38,3 +44,8 @@ type Instance struct {
Titles map[string]string `yaml:"titles"`
Break bool `yaml:"break"`
}
type OIDCClientConfig struct {
ClientID string
Scopes []string
}

View File

@@ -7,6 +7,11 @@ import (
"github.com/opencloud-eu/opencloud/services/webfinger/pkg/relations"
)
var (
nativeAppScopes = []string{"openid", "profile", "email", "offline_access"}
webAppScopes = []string{"openid", "profile", "email"}
)
// FullDefaultConfig returns a fully initialized default configuration
func FullDefaultConfig() *config.Config {
cfg := DefaultConfig()
@@ -49,8 +54,16 @@ func DefaultConfig() *config.Config {
},
},
},
IDP: "https://localhost:9200",
Insecure: false,
IDP: "https://localhost:9200",
Insecure: false,
AndroidClientID: "OpenCloudAndroid",
AndroidClientScopes: nativeAppScopes,
DesktopClientID: "OpenCloudDesktop",
DesktopClientScopes: nativeAppScopes,
IOSClientID: "OpenCloudIOS",
IOSClientScopes: nativeAppScopes,
WebClientID: "web",
WebClientScopes: webAppScopes,
}
}
@@ -78,4 +91,23 @@ func Sanitize(cfg *config.Config) {
if cfg.HTTP.Root != "/" {
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
}
cfg.OIDCClientConfigs = map[string]config.OIDCClientConfig{
"android": {
ClientID: cfg.AndroidClientID,
Scopes: cfg.AndroidClientScopes,
},
"desktop": {
ClientID: cfg.DesktopClientID,
Scopes: cfg.DesktopClientScopes,
},
"ios": {
ClientID: cfg.IOSClientID,
Scopes: cfg.IOSClientScopes,
},
"web": {
ClientID: cfg.WebClientID,
Scopes: cfg.WebClientScopes,
},
}
}

View File

@@ -57,7 +57,7 @@ func OpenCloudInstance(instances []config.Instance, openCloudURL string) (servic
}, nil
}
func (l *openCloudInstance) Add(ctx context.Context, jrd *webfinger.JSONResourceDescriptor) {
func (l *openCloudInstance) Add(ctx context.Context, _ string, jrd *webfinger.JSONResourceDescriptor) {
if jrd == nil {
jrd = &webfinger.JSONResourceDescriptor{}
}

View File

@@ -44,7 +44,7 @@ func TestOpenCloudInstanceAddLink(t *testing.T) {
"otherclaim": "someone",
})
jrd := webfinger.JSONResourceDescriptor{}
provider.Add(ctx, &jrd)
provider.Add(ctx, "", &jrd)
if len(jrd.Links) != 1 {
t.Errorf("provider returned wrong number of links: %v, expected 1", len(jrd.Links))

View File

@@ -3,28 +3,31 @@ package relations
import (
"context"
"github.com/opencloud-eu/opencloud/services/webfinger/pkg/config"
"github.com/opencloud-eu/opencloud/services/webfinger/pkg/service/v0"
"github.com/opencloud-eu/opencloud/services/webfinger/pkg/webfinger"
)
const (
OpenIDConnectRel = "http://openid.net/specs/connect/1.0/issuer"
OpenIDConnectDesktopRel = "http://openid.net/specs/connect/1.0/issuer/desktop"
OpenIDConnectMobileRel = "http://openid.net/specs/connect/1.0/issuer/mobile"
OpenIDConnectRel = "http://openid.net/specs/connect/1.0/issuer"
clientIDProp = "http://opencloud.eu/ns/oidc/client_id"
scopesProp = "http://opencloud.eu/ns/oidc/scopes"
)
type openIDDiscovery struct {
Href string
Href string
OIDCClients map[string]config.OIDCClientConfig
}
// OpenIDDiscovery adds the Openid Connect issuer relation
func OpenIDDiscovery(href string) service.RelationProvider {
func OpenIDDiscovery(href string, clients map[string]config.OIDCClientConfig) service.RelationProvider {
return &openIDDiscovery{
Href: href,
Href: href,
OIDCClients: clients,
}
}
func (l *openIDDiscovery) Add(_ context.Context, jrd *webfinger.JSONResourceDescriptor) {
func (l *openIDDiscovery) Add(_ context.Context, platform string, jrd *webfinger.JSONResourceDescriptor) {
if jrd == nil {
jrd = &webfinger.JSONResourceDescriptor{}
}
@@ -32,73 +35,12 @@ func (l *openIDDiscovery) Add(_ context.Context, jrd *webfinger.JSONResourceDesc
Rel: OpenIDConnectRel,
Href: l.Href,
})
}
// ClientIDProperty is the property URI for the OIDC client ID
const ClientIDProperty = "http://openid.net/specs/connect/1.0/client_id"
type openIDDiscoveryDesktop struct {
Href string
ClientID string
}
// OpenIDDiscoveryDesktop adds the OpenID Connect issuer relation for desktop clients.
// This allows identity providers that require separate OIDC clients per application type
// (like Authentik, Kanidm, Zitadel) to provide a distinct issuer URL for desktop clients.
// If clientID is provided, it will be included as a property in the link.
// See: https://github.com/opencloud-eu/desktop/issues/246
func OpenIDDiscoveryDesktop(href string, clientID string) service.RelationProvider {
return &openIDDiscoveryDesktop{
Href: href,
ClientID: clientID,
}
}
func (l *openIDDiscoveryDesktop) Add(_ context.Context, jrd *webfinger.JSONResourceDescriptor) {
if jrd == nil {
jrd = &webfinger.JSONResourceDescriptor{}
}
link := webfinger.Link{
Rel: OpenIDConnectDesktopRel,
Href: l.Href,
}
if l.ClientID != "" {
link.Properties = map[string]string{
ClientIDProperty: l.ClientID,
if platform != "" {
if clientConfig, ok := l.OIDCClients[platform]; ok {
jrd.Properties = make(map[string]any)
jrd.Properties[clientIDProp] = clientConfig.ClientID
jrd.Properties[scopesProp] = clientConfig.Scopes
}
}
jrd.Links = append(jrd.Links, link)
}
type openIDDiscoveryMobile struct {
Href string
ClientID string
}
// OpenIDDiscoveryMobile adds the OpenID Connect issuer relation for mobile clients.
// This allows identity providers that require separate OIDC clients per application type
// (like Authentik, Kanidm, Zitadel) to provide a distinct issuer URL for mobile clients.
// If clientID is provided, it will be included as a property in the link.
// See: https://github.com/opencloud-eu/desktop/issues/246
func OpenIDDiscoveryMobile(href string, clientID string) service.RelationProvider {
return &openIDDiscoveryMobile{
Href: href,
ClientID: clientID,
}
}
func (l *openIDDiscoveryMobile) Add(_ context.Context, jrd *webfinger.JSONResourceDescriptor) {
if jrd == nil {
jrd = &webfinger.JSONResourceDescriptor{}
}
link := webfinger.Link{
Rel: OpenIDConnectMobileRel,
Href: l.Href,
}
if l.ClientID != "" {
link.Properties = map[string]string{
ClientIDProperty: l.ClientID,
}
}
jrd.Links = append(jrd.Links, link)
}

View File

@@ -4,15 +4,27 @@ import (
"context"
"testing"
"github.com/opencloud-eu/opencloud/services/webfinger/pkg/config"
"github.com/opencloud-eu/opencloud/services/webfinger/pkg/webfinger"
)
func TestOpenidDiscovery(t *testing.T) {
provider := OpenIDDiscovery("http://issuer.url")
clients := map[string]config.OIDCClientConfig{
"web": {
ClientID: "web",
Scopes: []string{"openid", "profile", "email"},
},
"test": {
ClientID: "test",
Scopes: []string{"test"},
},
}
provider := OpenIDDiscovery("http://issuer.url", clients)
jrd := webfinger.JSONResourceDescriptor{}
provider.Add(context.Background(), &jrd)
provider.Add(context.Background(), "", &jrd)
if len(jrd.Links) != 1 {
t.Errorf("provider returned wrong number of links: %v, expected 1", len(jrd.Links))
@@ -23,4 +35,19 @@ func TestOpenidDiscovery(t *testing.T) {
if jrd.Links[0].Rel != "http://openid.net/specs/connect/1.0/issuer" {
t.Errorf("provider returned wrong openid connect rel: %v, expected %v", jrd.Links[0].Href, OpenIDConnectRel)
}
if len(jrd.Properties) != 0 {
t.Errorf("provider returned properties for empty platform: %v, expected 0", len(jrd.Properties))
}
jrd = webfinger.JSONResourceDescriptor{}
provider.Add(context.Background(), "test", &jrd)
if len(jrd.Properties) != 2 {
t.Errorf("provider returned wrong number of properties for platform test: %v, expected 2", len(jrd.Properties))
}
if jrd.Properties["http://opencloud.eu/ns/oidc/client_id"] != "test" {
t.Errorf("provider returned wrong client_id property: %v, expected %v", jrd.Properties["http://opencloud.eu/ns/oidc/client_id"], "test")
}
if scopes, ok := jrd.Properties["http://opencloud.eu/ns/oidc/scopes"].([]string); !ok || len(scopes) != 1 || scopes[0] != "test" {
t.Errorf("provider returned wrong scopes property: %v, expected %v", jrd.Properties["http://opencloud.eu/ns/oidc/scopes"], []string{"test"})
}
}

View File

@@ -127,7 +127,9 @@ func WebfingerHandler(service svc.Service) func(w http.ResponseWriter, r *http.R
rels := r.URL.Query()["rel"]
jrd, err := service.Webfinger(ctx, queryTarget, rels)
platform := r.URL.Query().Get("platform")
jrd, err := service.Webfinger(ctx, queryTarget, rels, platform)
if errors.Is(err, serviceErrors.ErrNotFound) {
// from https://www.rfc-editor.org/rfc/rfc7033#section-4.2
//

View File

@@ -23,7 +23,7 @@ type instrument struct {
}
// Webfinger implements the Service interface.
func (i instrument) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string) (webfinger.JSONResourceDescriptor, error) {
func (i instrument) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string, platform string) (webfinger.JSONResourceDescriptor, error) {
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
us := v * 1000000
@@ -35,5 +35,5 @@ func (i instrument) Webfinger(ctx context.Context, queryTarget *url.URL, rels []
i.metrics.Counter.WithLabelValues().Inc()
return i.next.Webfinger(ctx, queryTarget, rels)
return i.next.Webfinger(ctx, queryTarget, rels, platform)
}

View File

@@ -22,11 +22,11 @@ type logging struct {
}
// Webfinger implements the Service interface.
func (l logging) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string) (webfinger.JSONResourceDescriptor, error) {
func (l logging) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string, platform string) (webfinger.JSONResourceDescriptor, error) {
l.logger.Debug().
Str("query_target", queryTarget.String()).
Strs("rel", rels).
Msg("Webfinger")
return l.next.Webfinger(ctx, queryTarget, rels)
return l.next.Webfinger(ctx, queryTarget, rels, platform)
}

View File

@@ -45,11 +45,11 @@ type Service interface {
// }
// ]
// }
Webfinger(ctx context.Context, queryTarget *url.URL, rels []string) (webfinger.JSONResourceDescriptor, error)
Webfinger(ctx context.Context, queryTarget *url.URL, rels []string, platform string) (webfinger.JSONResourceDescriptor, error)
}
type RelationProvider interface {
Add(ctx context.Context, jrd *webfinger.JSONResourceDescriptor)
Add(ctx context.Context, platform string, jrd *webfinger.JSONResourceDescriptor)
}
// New returns a new instance of Service
@@ -81,7 +81,7 @@ type svc struct {
// - one that looks up in instance by id (use template, read from json, read from ldap, read from graph)
// Webfinger implements the service interface
func (s svc) Webfinger(ctx context.Context, queryTarget *url.URL, rel []string) (webfinger.JSONResourceDescriptor, error) {
func (s svc) Webfinger(ctx context.Context, queryTarget *url.URL, rel []string, platform string) (webfinger.JSONResourceDescriptor, error) {
jrd := webfinger.JSONResourceDescriptor{
Subject: queryTarget.String(),
@@ -90,13 +90,13 @@ func (s svc) Webfinger(ctx context.Context, queryTarget *url.URL, rel []string)
if len(rel) == 0 {
// add all configured relation providers
for _, relation := range s.relationProviders {
relation.Add(ctx, &jrd)
relation.Add(ctx, platform, &jrd)
}
} else {
// only add requested relations
for _, r := range rel {
if relation, ok := s.relationProviders[r]; ok {
relation.Add(ctx, &jrd)
relation.Add(ctx, platform, &jrd)
}
}
}

View File

@@ -23,7 +23,7 @@ type tracing struct {
}
// Webfinger implements the Service interface.
func (t tracing) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string) (webfinger.JSONResourceDescriptor, error) {
func (t tracing) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string, platform string) (webfinger.JSONResourceDescriptor, error) {
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(
@@ -34,5 +34,5 @@ func (t tracing) Webfinger(ctx context.Context, queryTarget *url.URL, rels []str
ctx, span := t.tp.Tracer("webfinger").Start(ctx, "Webfinger", spanOpts...)
defer span.End()
return t.next.Webfinger(ctx, queryTarget, rels)
return t.next.Webfinger(ctx, queryTarget, rels, platform)
}

View File

@@ -56,7 +56,7 @@ type JSONResourceDescriptor struct {
// values are strings or null.
//
// The "properties" member is OPTIONAL in the JRD.
Properties map[string]string `json:"properties,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
// Links is an array of objects that contain link relation information
//
// The "links" array is OPTIONAL in the JRD.