Skip to content
Open
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A configurable OpenID Connect Provider for Go.
* [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
* [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
* [`RFC 6749` - The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749.html)
* [`RFC 8414` - OAuth 2.0 Authorization Server Metadata](https://www.rfc-editor.org/rfc/rfc8414.html)
* [`RFC 9068` - JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
* [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html)
* [`RFC 7591` - OAuth 2.0 Dynamic Client Registration Protocol (DCR)](https://www.rfc-editor.org/rfc/rfc7591.html)
Expand Down Expand Up @@ -907,6 +908,28 @@ op, _ := provider.New(
)
```

## Client resolution

Use `provider.WithClientFunc` when clients live in an external store but
must not be allowed to register themselves:

```go
op, _ := provider.New(
...,
provider.WithClientFunc(func(ctx context.Context, id string) (*goidc.Client, error) {
return clientStore.Client(ctx, id)
}),
)
```

The callback must return a non-nil client with the requested ID on success,
`goidc.ErrNotFound` only for unknown clients, and other lookup errors unchanged.
It may be called concurrently. Return a fresh client snapshot on each lookup
so disablement and key rotation take effect. Fetched JWKS are cached on that
snapshot; implementations that reuse client pointers own cache invalidation
and synchronization. Static clients take precedence. The callback cannot be
combined with DCR or OpenID Federation, which provide their own client sources.

## [Dynamic Client Registration (DCR)](https://www.rfc-editor.org/rfc/rfc7591.html)

DCR allows clients to register and update themselves dynamically:
Expand Down
19 changes: 19 additions & 0 deletions internal/client/authn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1217,6 +1217,25 @@ func TestAuthenticated(t *testing.T) {
}
}

func TestAuthenticated_PreservesClientResolverOperationalError(t *testing.T) {
ctx := oidctest.NewContext(t)
ctx.Request.PostForm = map[string][]string{"client_id": {"client"}}
resolverErr := errors.New("client store unavailable")
ctx.ClientFunc = func(context.Context, string) (*goidc.Client, error) {
return nil, resolverErr
}

_, err := client.Authenticated(ctx, client.AuthnContextToken)

if !errors.Is(err, resolverErr) {
t.Fatalf("Authenticated() error = %v, want wrapped %v", err, resolverErr)
}
var oidcErr goidc.Error
if errors.As(err, &oidcErr) {
t.Fatalf("operational error was converted to protocol error %q", oidcErr.Code)
}
}

func TestExtractID(t *testing.T) {
tests := []struct {
name string
Expand Down
4 changes: 4 additions & 0 deletions internal/client/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ func Client(ctx oidc.Context, id string) (*goidc.Client, error) {
}
}

if ctx.ClientFunc != nil {
return ctx.Client(id)
}

if ctx.OpenIDFedEnabled && strutil.IsURL(id) {
return ctx.OpenIDFedClient(id)
}
Expand Down
158 changes: 158 additions & 0 deletions internal/client/util_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package client

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
Expand All @@ -15,12 +17,61 @@ import (
)

func TestClient(t *testing.T) {
resolverErr := errors.New("client store unavailable")
tests := []struct {
name string
setup func(*testing.T) (oidc.Context, string)
wantClientID string
wantSecret string
wantErr error
}{
{
name: "resolved client",
setup: func(t *testing.T) (oidc.Context, string) {
ctx := oidctest.NewContext(t)
ctx.ClientFunc = func(_ context.Context, id string) (*goidc.Client, error) {
return &goidc.Client{ID: id}, nil
}
return ctx, "resolved_client"
},
wantClientID: "resolved_client",
},
{
name: "static client takes precedence over resolver",
setup: func(t *testing.T) (oidc.Context, string) {
ctx := oidctest.NewContext(t)
staticClient := &goidc.Client{ID: "shared_client", Secret: "static"}
ctx.StaticClients = append(ctx.StaticClients, staticClient)
ctx.ClientFunc = func(_ context.Context, id string) (*goidc.Client, error) {
return &goidc.Client{ID: id, Secret: "resolved"}, nil
}
return ctx, staticClient.ID
},
wantClientID: "shared_client",
wantSecret: "static",
},
{
name: "resolver not found",
setup: func(t *testing.T) (oidc.Context, string) {
ctx := oidctest.NewContext(t)
ctx.ClientFunc = func(context.Context, string) (*goidc.Client, error) {
return nil, fmt.Errorf("lookup failed: %w", goidc.ErrNotFound)
}
return ctx, "missing_client"
},
wantErr: goidc.ErrNotFound,
},
{
name: "resolver operational error",
setup: func(t *testing.T) (oidc.Context, string) {
ctx := oidctest.NewContext(t)
ctx.ClientFunc = func(context.Context, string) (*goidc.Client, error) {
return nil, resolverErr
}
return ctx, "client"
},
wantErr: resolverErr,
},
{
name: "static client",
setup: func(t *testing.T) (oidc.Context, string) {
Expand Down Expand Up @@ -153,10 +204,53 @@ func TestClient(t *testing.T) {
if got.ID != test.wantClientID {
t.Fatalf("client ID = %q, want %q", got.ID, test.wantClientID)
}
if test.wantSecret != "" && got.Secret != test.wantSecret {
t.Fatalf("client secret = %q, want %q", got.Secret, test.wantSecret)
}
})
}
}

func TestClient_ResolverIsConsultedForEveryLookup(t *testing.T) {
ctx := oidctest.NewContext(t)
clientID := "client"
current := &goidc.Client{ID: clientID, Secret: "first"}
calls := 0
ctx.ClientFunc = func(context.Context, string) (*goidc.Client, error) {
calls++
if current == nil {
return nil, goidc.ErrNotFound
}
copy := *current
return &copy, nil
}

first, err := Client(ctx, clientID)
if err != nil {
t.Fatalf("first Client() error = %v", err)
}
if first.Secret != "first" {
t.Fatalf("first secret = %q, want %q", first.Secret, "first")
}

current = &goidc.Client{ID: clientID, Secret: "rotated"}
second, err := Client(ctx, clientID)
if err != nil {
t.Fatalf("second Client() error = %v", err)
}
if second.Secret != "rotated" {
t.Fatalf("second secret = %q, want %q", second.Secret, "rotated")
}

current = nil
if _, err := Client(ctx, clientID); !errors.Is(err, goidc.ErrNotFound) {
t.Fatalf("disabled Client() error = %v, want %v", err, goidc.ErrNotFound)
}
if calls != 3 {
t.Fatalf("resolver calls = %d, want 3", calls)
}
}

func TestFetchPublicJWKS(t *testing.T) {

// Given.
Expand Down Expand Up @@ -198,6 +292,70 @@ func TestFetchPublicJWKS(t *testing.T) {
}
}

func TestJWKS_ClientFuncReturnsFreshSnapshot(t *testing.T) {
ctx := oidctest.NewContext(t)
firstPrivateKey := oidctest.PrivatePS256JWK(t, "first_key", goidc.KeyUsageSignature)
rotatedPrivateKey := oidctest.PrivatePS256JWK(t, "rotated_key", goidc.KeyUsageSignature)
firstKey := firstPrivateKey.Public()
rotatedKey := rotatedPrivateKey.Public()
currentKey := firstKey
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
if err := json.NewEncoder(w).Encode(goidc.JSONWebKeySet{Keys: []goidc.JSONWebKey{currentKey}}); err != nil {
t.Fatal(err)
}
}))
defer server.Close()

resolvedClient := &goidc.Client{
ID: "client",
ClientMeta: goidc.ClientMeta{
JWKSURI: server.URL,
},
}
ctx.ClientFunc = func(context.Context, string) (*goidc.Client, error) {
copy := *resolvedClient
return &copy, nil
}

first, err := Client(ctx, resolvedClient.ID)
if err != nil {
t.Fatalf("first Client() error = %v", err)
}
firstJWKS, err := JWKS(ctx, first)
if err != nil {
t.Fatalf("first JWKS() error = %v", err)
}
if firstJWKS.Keys[0].KeyID != firstKey.KeyID {
t.Fatalf("first key ID = %q, want %q", firstJWKS.Keys[0].KeyID, firstKey.KeyID)
}

// Reuse keys within this snapshot, then observe rotation on the next lookup.
if _, err := JWKS(ctx, first); err != nil {
t.Fatalf("cached JWKS() error = %v", err)
}
if requests != 1 {
t.Fatalf("JWKS requests within one snapshot = %d, want 1", requests)
}

currentKey = rotatedKey
second, err := Client(ctx, resolvedClient.ID)
if err != nil {
t.Fatalf("second Client() error = %v", err)
}
secondJWKS, err := JWKS(ctx, second)
if err != nil {
t.Fatalf("second JWKS() error = %v", err)
}
if secondJWKS.Keys[0].KeyID != rotatedKey.KeyID {
t.Fatalf("rotated key ID = %q, want %q", secondJWKS.Keys[0].KeyID, rotatedKey.KeyID)
}
if requests != 2 {
t.Fatalf("JWKS requests = %d, want 2", requests)
}
}

func TestJWKByAlg(t *testing.T) {
tests := []struct {
name string
Expand Down
6 changes: 4 additions & 2 deletions internal/discovery/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import (

func RegisterHandlers(router *http.ServeMux, config *oidc.Configuration, middlewares ...goidc.MiddlewareFunc) {
issuer, _ := url.Parse(config.Host)
router.Handle("GET /.well-known/openid-configuration"+strings.TrimSuffix(issuer.Path, "/"),
goidc.ApplyMiddlewares(oidc.Handler(config, handleWellKnown), middlewares...))
issuerPath := strings.TrimSuffix(issuer.Path, "/")
metadataHandler := goidc.ApplyMiddlewares(oidc.Handler(config, handleWellKnown), middlewares...)
router.Handle("GET "+issuerPath+"/.well-known/openid-configuration", metadataHandler)
router.Handle("GET /.well-known/oauth-authorization-server"+issuerPath, metadataHandler)

router.Handle("GET "+config.EndpointPrefix+config.JWKSEndpoint,
goidc.ApplyMiddlewares(oidc.Handler(config, handleJWKS), middlewares...))
Expand Down
80 changes: 80 additions & 0 deletions internal/discovery/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package discovery

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/luikyv/go-oidc/internal/oidctest"
)

func TestRegisterHandlers_AuthorizationServerMetadata(t *testing.T) {
tests := []struct {
name string
issuer string
paths []string
}{
{
name: "issuer without path",
issuer: "https://example.com",
paths: []string{
"/.well-known/oauth-authorization-server",
"/.well-known/openid-configuration",
},
},
{
name: "issuer with path",
issuer: "https://example.com/tenant/issuer",
paths: []string{
"/.well-known/oauth-authorization-server/tenant/issuer",
"/tenant/issuer/.well-known/openid-configuration",
},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := oidctest.NewContext(t)
ctx.Host = test.issuer
ctx.DCREnabled = false
mux := http.NewServeMux()
RegisterHandlers(mux, ctx.Configuration)

for _, path := range test.paths {
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))

if recorder.Code != http.StatusOK {
t.Fatalf("GET %s status = %d, want %d", path, recorder.Code, http.StatusOK)
}
var metadata map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &metadata); err != nil {
t.Fatalf("decode metadata from %s: %v", path, err)
}
if metadata["issuer"] != test.issuer {
t.Fatalf("GET %s issuer = %v, want %q", path, metadata["issuer"], test.issuer)
}
if _, advertised := metadata["registration_endpoint"]; advertised {
t.Fatalf("GET %s advertises registration_endpoint while DCR is disabled", path)
}
}
})
}
}

func TestRegisterHandlersRejectsNonStandardPathBasedOIDCMetadataRoute(t *testing.T) {
ctx := oidctest.NewContext(t)
ctx.Host = "https://example.com/tenant/issuer"
mux := http.NewServeMux()
RegisterHandlers(mux, ctx.Configuration)

recorder := httptest.NewRecorder()
mux.ServeHTTP(
recorder,
httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration/tenant/issuer", nil),
)
if recorder.Code != http.StatusNotFound {
t.Fatalf("non-standard OIDC metadata route status = %d, want %d", recorder.Code, http.StatusNotFound)
}
}
1 change: 1 addition & 0 deletions internal/oidc/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type Configuration struct {
SubIdentifierTypes []goidc.SubIdentifierType
PairwiseSubjectFunc goidc.PairwiseSubjectFunc
StaticClients []*goidc.Client
ClientFunc goidc.ClientFunc
// IssuerRespParamEnabled indicates if the "iss" parameter will be
// returned when redirecting the user back to the client application.
IssuerRespParamEnabled bool
Expand Down
4 changes: 4 additions & 0 deletions internal/oidc/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ func (ctx Context) DCRDeleteClient(id string) error {
return ctx.DCRManager.DeleteClient(ctx, id)
}

func (ctx Context) Client(id string) (*goidc.Client, error) {
return ctx.ClientFunc(ctx, id)
}

func (ctx Context) ValidateInitalAccessToken(token string) error {
return ctx.DCRValidateInitialTokenFunc(ctx, token)
}
Expand Down
Loading
Loading