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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ the limits worth knowing before you build on it.
- [RFC 9207 - Authorization Server Issuer Identification](https://datatracker.ietf.org/doc/html/rfc9207) -
every authorization response redirected to the client, errors included,
carries `iss`
- [OAuth Client ID Metadata Document](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-02) -
draft `-02`, for the hosts named with `--client-id-metadata-document-host`,
public clients only
- [OAuth 2.0 Multiple Response Type Encoding Practices](https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html)

### JOSE and cryptography
Expand Down Expand Up @@ -158,6 +161,21 @@ to ask the same of every client, and `--dynamic-client-secret-expiration` to
expire the credentials of a dynamic registration, which by default do not
expire.

A client can also be identified by the `https` URL of a JSON document which
describes it, its Client ID Metadata Document. Lico fetches the document
instead of keeping a registration, for the hosts named with
`--client-id-metadata-document-host`, which can be given several times:
`app.example.com` allows that host as listed, `*.example.com` the subdomains of
`example.com`, and `*` any public host. A wildcard never matches an address, a
name without a dot or a name which only means something inside a local network,
such as `localhost`, `*.local`, `*.internal` or `*.home.arpa`, and no document is
ever fetched from a loopback, private or other special-use address. Such a
client is public and has to use PKCE, its non-loopback redirect URIs have to be
on the host of its `client_id`, and the consent screen names it by that host,
with the name the document claims beside it. A document is cached as its
`Cache-Control` says, between one minute and a day, and is used for up to a day
longer while its host cannot be reached.

### Protected resources

Lico can issue access tokens for an API which is not Lico itself, a
Expand Down
5 changes: 5 additions & 0 deletions bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@ func (bs *bootstrap) initialize(settings *Settings) error {
logger.Infoln("PKCE is required for all clients")
}

bs.config.Config.ClientIDMetadataDocumentHosts = settings.ClientIDMetadataDocumentHosts
if len(bs.config.Config.ClientIDMetadataDocumentHosts) > 0 {
logger.WithField("hosts", bs.config.Config.ClientIDMetadataDocumentHosts).Infoln("client id metadata documents are enabled")
}

encryptionSecretFn := settings.EncryptionSecretFile

if encryptionSecretFn != "" {
Expand Down
2 changes: 1 addition & 1 deletion bootstrap/managers.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func newManagers(ctx context.Context, bs *bootstrap) (*managers.Managers, error)
mgrs.Set("code", code)

// Identifier client registry manager.
clients, err := identityClients.NewRegistry(ctx, bs.config.IssuerIdentifierURI, bs.config.IdentifierRegistrationConf, bs.config.Config.AllowDynamicClientRegistration, time.Duration(bs.config.DyamicClientSecretDurationSeconds)*time.Second, logger)
clients, err := identityClients.NewRegistry(ctx, bs.config.IssuerIdentifierURI, bs.config.IdentifierRegistrationConf, bs.config.Config.AllowDynamicClientRegistration, time.Duration(bs.config.DyamicClientSecretDurationSeconds)*time.Second, bs.config.Config.ClientIDMetadataDocumentHosts, logger)
if err != nil {
return nil, fmt.Errorf("failed to create client registry: %v", err)
}
Expand Down
1 change: 1 addition & 0 deletions bootstrap/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Settings struct {
AllowClientSignedLogins bool
AllowDynamicClientRegistration bool
RequirePKCE bool
ClientIDMetadataDocumentHosts []string
EncryptionSecretFile string
Listen string
IdentifierClientDisabled bool
Expand Down
1 change: 1 addition & 0 deletions cmd/licod/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func commandServe() *cobra.Command {
serveCmd.Flags().BoolVar(&cfg.AllowClientSignedLogins, "allow-client-signed-logins", false, "Allow sign in of client controlled signed login users")
serveCmd.Flags().BoolVar(&cfg.AllowDynamicClientRegistration, "allow-dynamic-client-registration", false, "Allow dynamic OAuth2 client registration")
serveCmd.Flags().BoolVar(&cfg.RequirePKCE, "require-pkce", false, "Require PKCE for all clients using the authorization code flow, dynamically registered clients always require it")
serveCmd.Flags().StringArrayVar(&cfg.ClientIDMetadataDocumentHosts, "client-id-metadata-document-host", nil, "Fetch Client ID Metadata Documents from this host (repeatable, \"*\" for all public)")
serveCmd.Flags().Uint64Var(&cfg.AccessTokenDurationSeconds, "access-token-expiration", 60*10, "Expiration time of access tokens in seconds since generated") // 10 Minutes.
serveCmd.Flags().Uint64Var(&cfg.IDTokenDurationSeconds, "id-token-expiration", 60*60, "Expiration time of id tokens in seconds since generated") // 1 Hour.
serveCmd.Flags().Uint64Var(&cfg.RefreshTokenDurationSeconds, "refresh-token-expiration", 60*60*24*365*3, "Expiration time of refresh tokens in seconds since generated") // 3 Years.
Expand Down
4 changes: 4 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,8 @@ type Config struct {
AllowClientSignedLogins bool
AllowDynamicClientRegistration bool
RequirePKCE bool

// ClientIDMetadataDocumentHosts are the hosts whose Client ID Metadata
// Documents are fetched, "*" for any public host. None disables them.
ClientIDMetadataDocumentHosts []string
}
63 changes: 56 additions & 7 deletions docs/libregraph-connect-api-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ info:
- Proof Key for Code Exchange (RFC 7636), `S256` only
- Resource Indicators for OAuth 2.0 (RFC 8707)
- OAuth 2.0 Authorization Server Issuer Identification (RFC 9207)
- OAuth Client ID Metadata Document (draft `-02`, only for configured hosts)
- Dynamic Client Registration (only when explicitly enabled, see below)
- SAML2 and OAuth2 integration with external identity providers

Expand Down Expand Up @@ -196,6 +197,15 @@ paths:
- name: client_id
in: query
required: true
description: |
A client registered in `--identifier-registration-conf` or dynamically, or the
`https` URL of the client's Client ID Metadata Document when its host is named
with `--client-id-metadata-document-host`. Such a document is fetched and
cached by this server and makes a public client which must use PKCE. Its
redirect URIs are either on the document's own host or loopback, never both.

A `client_id` which does not resolve, including a document which is not
allowed, cannot be fetched or is invalid, is refused and never leads to a code.
schema:
type: string
- name: redirect_uri
Expand Down Expand Up @@ -267,8 +277,9 @@ paths:
in: query
description: |
PKCE challenge (RFC 7636). Accepted on every code flow and verified at the
token endpoint. Required for dynamically registered clients, for static clients
with `require_pkce`, and for every client when the server runs with
token endpoint. Required for dynamically registered clients, for clients
identified by a Client ID Metadata Document, for static clients with
`require_pkce`, and for every client when the server runs with
`--require-pkce`.
schema:
type: string
Expand Down Expand Up @@ -589,7 +600,7 @@ paths:

A client registering with `token_endpoint_auth_method: none` is public and gets no
secret. Every dynamically registered client must use PKCE, since nobody vouches
for it.
for it, and is shown as unverified on the consent screen.
operationId: registerClient
requestBody:
required: true
Expand Down Expand Up @@ -1225,12 +1236,19 @@ components:
type: array
description: |
Emitted as `["S256"]`. A code challenge is accepted on every code flow and, when
present, verified at the token endpoint. Dynamically registered clients and static
clients with `require_pkce` must send one, and every client must when the server
runs with `--require-pkce`. `plain` is rejected by the authorization endpoint and
is therefore not advertised.
present, verified at the token endpoint. Dynamically registered clients, clients
identified by a Client ID Metadata Document and static clients with
`require_pkce` must send one, and every client must when the server runs with
`--require-pkce`. `plain` is rejected by the authorization endpoint and is
therefore not advertised.
items:
type: string
client_id_metadata_document_supported:
type: boolean
description: |
Emitted as `true` when at least one `--client-id-metadata-document-host` is
configured, and omitted otherwise. A client may then use the `https` URL of its
Client ID Metadata Document as `client_id`.
authorization_response_iss_parameter_supported:
type: boolean
description: |
Expand Down Expand Up @@ -1712,6 +1730,11 @@ components:
max_age:
type: string
description: Seconds, as a string. Must parse as an integer if present.
resource:
type: string
description: |
The `resource` parameter of the authorization request (RFC 8707), passed on so
the consent screen can name the protected resource the request is for.

HelloResponse:
type: object
Expand Down Expand Up @@ -1755,6 +1778,8 @@ components:
$ref: '#/components/schemas/ScopesMap'
client:
$ref: '#/components/schemas/ClientDetails'
resource:
$ref: '#/components/schemas/ResourceDetails'
meta:
$ref: '#/components/schemas/Meta'
branding:
Expand Down Expand Up @@ -1874,6 +1899,30 @@ components:
format: uri
trusted:
type: boolean
unverified:
type: boolean
description: |
True for a client nobody vouches for: one which registered itself, or one whose
`client_id` is the URL of its Client ID Metadata Document. For the latter
`display_name` is the host of that URL.
claimed_name:
type: string
description: |
The `client_name` a Client ID Metadata Document gives. Self-asserted, so it is
only shown beside the host. Omitted when there is none.

ResourceDetails:
type: object
description: |
The protected resource a consent is for, as registered with this server. Present
on a consent answer only when the request named a registered resource.
required: [id]
properties:
id:
type: string
format: uri
name:
type: string

Meta:
type: object
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/longsleep/rndm v1.2.0
github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103
github.com/orcaman/concurrent-map v1.0.0
github.com/pquerna/cachecontrol v0.2.0
github.com/prometheus/client_golang v1.23.2
github.com/rs/cors v1.11.1
github.com/russellhaering/goxmldsig v1.6.0
Expand All @@ -47,7 +48,6 @@ require (
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pquerna/cachecontrol v0.2.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
Expand Down
12 changes: 12 additions & 0 deletions identifier/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,18 @@ handleHelloLoop:
response.Meta = &meta.Meta{
Scopes: scopes.NewScopesFromIDs(r.Scopes, i.meta.Scopes),
}
// The resource is named by the operator's registration, unlike
// the client, so it is what the consent screen can vouch for. An
// unknown one never gets here, the authorization endpoint refuses
// it first.
if r.RawResource != "" && i.resources != nil {
if resource, ok := i.resources.Get(r.RawResource); ok {
response.Resource = &ResourceDetails{
ID: resource.ID,
Name: resource.Name,
}
}
}
}

// Add authorize endpoint URI as continue URI.
Expand Down
135 changes: 135 additions & 0 deletions identifier/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright 2026 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package identifier

import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"

"github.com/libregraph/oidc-go"
"github.com/sirupsen/logrus"

"github.com/libregraph/lico/identifier/meta"
"github.com/libregraph/lico/identifier/meta/scopes"
"github.com/libregraph/lico/identity/clients"
"github.com/libregraph/lico/identity/resources"
)

// TestHelloNamesTheResource covers the consent answer of the hello endpoint,
// which names the protected resource a request is for by its registration,
// since that name, unlike the client's, is one the operator vouches for.
func TestHelloNamesTheResource(t *testing.T) {
i := newTestHelloIdentifier(t)
user := &IdentifiedUser{username: "user1@example.com"}

hello := func(t *testing.T, resource string) *HelloResponse {
t.Helper()

r := &HelloRequest{
Flow: FlowOAuth,
ClientID: "unittestclient",

Check failure on line 50 in identifier/api_test.go

View workflow job for this annotation

GitHub Actions / build

string `unittestclient` has 2 occurrences, make it a constant (goconst)
RawRedirectURI: "http://localhost/callback",

Check failure on line 51 in identifier/api_test.go

View workflow job for this annotation

GitHub Actions / build

string `http://localhost/callback` has 2 occurrences, make it a constant (goconst)
RawScope: "openid",
RawResource: resource,
}
if err := r.parse(); err != nil {
t.Fatal(err)
}
response, err := i.writeHelloResponse(nil, httptest.NewRequest("GET", "/", http.NoBody), r, user)
if err != nil {
t.Fatal(err)
}
if response.Next != FlowConsent {
t.Fatalf("hello did not ask for consent, got %q", response.Next)
}
return response
}

t.Run("a registered resource is named", func(t *testing.T) {
// Not the canonical form, which the registry resolves.
response := hello(t, "https://MCP.example.com/mcp/")
if response.Resource == nil {
t.Fatal("hello named no resource")
}
if response.Resource.ID != "https://mcp.example.com/mcp" || response.Resource.Name != "Example MCP server" {
t.Errorf("resource was incorrect, got %+v", response.Resource)
}
})

t.Run("no resource names none", func(t *testing.T) {
if response := hello(t, ""); response.Resource != nil {
t.Errorf("hello named a resource, got %+v", response.Resource)
}
})

t.Run("an unknown resource names none", func(t *testing.T) {
if response := hello(t, "https://other.example.com/mcp"); response.Resource != nil {
t.Errorf("hello named a resource, got %+v", response.Resource)
}
})
}

// newTestHelloIdentifier returns an identifier with a registered client and a
// registered resource, enough to answer hello for a consent.
func newTestHelloIdentifier(t *testing.T) *Identifier {
t.Helper()

ctx := context.Background()
logger := logrus.New()
logger.SetLevel(logrus.PanicLevel)

clientRegistry, err := clients.NewRegistry(ctx, nil, "", false, 0, nil, logger)
if err != nil {
t.Fatal(err)
}
if err := clientRegistry.Register(&clients.ClientRegistration{
ID: "unittestclient",
Name: "Unit test client",
ApplicationType: oidc.ApplicationTypeNative,
RedirectURIs: []string{"http://localhost/callback"},
}); err != nil {
t.Fatal(err)
}

fn := filepath.Join(t.TempDir(), "identifier-registration.yaml")
conf := "resources:\n" +
" - id: https://mcp.example.com/mcp\n" +
" name: Example MCP server\n"
if err := os.WriteFile(fn, []byte(conf), 0o600); err != nil {
t.Fatal(err)
}
resourceRegistry, err := resources.NewRegistry(ctx, fn, logger)
if err != nil {
t.Fatal(err)
}

authorizationEndpointURI, _ := url.Parse("https://example.com/signin/v1/identifier/_/authorize")
return &Identifier{
Config: &Config{},
clients: clientRegistry,
resources: resourceRegistry,
meta: &meta.Meta{Scopes: &scopes.Scopes{}},
authorizationEndpointURI: authorizationEndpointURI,
logger: logger,
}
}
2 changes: 1 addition & 1 deletion identifier/backends/libregraph/libregraph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ func newTestBackendWithRegistry(t *testing.T, status int, body string) (*LibreGr
logger := logrus.New()
logger.SetLevel(logrus.PanicLevel)

registry, err := identityClients.NewRegistry(context.Background(), nil, "", false, 0, logger)
registry, err := identityClients.NewRegistry(context.Background(), nil, "", false, 0, nil, logger)
if err != nil {
httpServer.Close()
t.Fatal(err)
Expand Down
Loading
Loading