Skip to content

Commit edbe590

Browse files
leifjGitHub Copilot
andcommitted
feat(verifier): add support for static OIDC client configuration
This PR adds support for configuring static OIDC clients in the verifier, allowing pre-defined clients to be used without database registration. Changes: - Add StaticOIDCClient configuration model with all standard OIDC fields - Implement unified client lookup (static config takes precedence over DB) - Support both confidential and public client types - Add bcrypt-based authentication for confidential clients - Apply sensible defaults for optional fields (grant_types, response_types, etc.) The static client configuration supports: - client_id, client_secret (optional for public clients) - redirect_uris with validation - allowed_scopes, grant_types, response_types - token_endpoint_auth_method (client_secret_basic, client_secret_post, none) - client_name for display purposes Co-authored-by: GitHub Copilot <copilot@github.com>
1 parent a779557 commit edbe590

File tree

7 files changed

+535
-120
lines changed

7 files changed

+535
-120
lines changed

config.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,26 @@ verifier:
171171
refresh_token_duration: 86400 # Refresh token duration in seconds (24 hours)
172172
subject_type: "pairwise"
173173
subject_salt: "change-this-in-production"
174+
# Static OIDC clients (optional)
175+
# Pre-configured clients that don't require dynamic registration
176+
# These are checked in addition to dynamically registered clients in the database
177+
# Note: secrets.yaml currently only supports oidc.subject_salt, not static client secrets.
178+
# For production, consider using dynamic client registration or environment variables.
179+
# static_clients:
180+
# - client_id: "example-client-id"
181+
# client_secret: "" # Set via environment variable or leave empty for public clients
182+
# redirect_uris:
183+
# - "https://example.com/callback"
184+
# allowed_scopes: # If omitted, defaults to openid, profile, email, address, phone
185+
# - "openid"
186+
# - "profile"
187+
# - "pid"
188+
# token_endpoint_auth_method: "none" # Use "none" for public clients (no secret required)
189+
# grant_types: # Optional, default: ["authorization_code"]
190+
# - "authorization_code"
191+
# response_types: # Optional, default: ["code"]
192+
# - "code"
193+
# client_name: "Example Static Client" # Optional
174194
openid4vp:
175195
presentation_timeout: 300
176196
# Template-based presentation requests (optional)

internal/verifier/apiv1/client.go

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package apiv1
33
import (
44
"context"
55
"crypto/sha256"
6+
"crypto/subtle"
67
"crypto/x509"
78
"encoding/base64"
89
"fmt"
@@ -18,6 +19,8 @@ import (
1819
"vc/pkg/openid4vp"
1920
"vc/pkg/pki"
2021
"vc/pkg/trace"
22+
23+
"golang.org/x/crypto/bcrypt"
2124
)
2225

2326
// Client holds the public api object
@@ -158,36 +161,99 @@ func (c *Client) containsOIDC(slice []string, value string) bool {
158161
return false
159162
}
160163

161-
// authenticateClient validates client credentials for the token endpoint
162-
func (c *Client) authenticateClient(ctx context.Context, clientID, clientSecret string) (*db.Client, error) {
164+
// verifyPlaintextSecret performs constant-time comparison of plaintext secrets
165+
func verifyPlaintextSecret(provided, stored string) bool {
166+
return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1
167+
}
168+
169+
// getClientByID looks up a client by ID, checking both database and static configuration.
170+
// Returns the client and a boolean indicating if it's a static client (plaintext secret).
171+
func (c *Client) getClientByID(ctx context.Context, clientID string) (*db.Client, bool, error) {
172+
// First, try to find the client in the database (dynamically registered clients)
163173
client, err := c.db.Clients.GetByClientID(ctx, clientID)
174+
if err != nil {
175+
return nil, false, err
176+
}
177+
if client != nil {
178+
return client, false, nil
179+
}
180+
181+
// If not found in database, check static clients from configuration
182+
if c.cfg.Verifier.OIDC != nil {
183+
for _, staticClient := range c.cfg.Verifier.OIDC.StaticClients {
184+
if staticClient.ClientID == clientID {
185+
// Determine allowed scopes: empty means all scopes allowed
186+
allowedScopes := staticClient.AllowedScopes
187+
if len(allowedScopes) == 0 {
188+
// Default to common OIDC scopes when not specified
189+
allowedScopes = []string{"openid", "profile", "email", "address", "phone"}
190+
}
191+
192+
// Convert static client config to db.Client for consistent handling
193+
return &db.Client{
194+
ClientID: clientID,
195+
ClientSecretHash: staticClient.ClientSecret, // Plaintext for static clients
196+
RedirectURIs: staticClient.RedirectURIs,
197+
GrantTypes: getOrDefault(staticClient.GrantTypes, []string{"authorization_code"}),
198+
ResponseTypes: getOrDefault(staticClient.ResponseTypes, []string{"code"}),
199+
TokenEndpointAuthMethod: getOrDefaultString(staticClient.TokenEndpointAuthMethod, "client_secret_basic"),
200+
AllowedScopes: allowedScopes,
201+
ClientName: staticClient.ClientName,
202+
}, true, nil // true = static client (plaintext secret)
203+
}
204+
}
205+
}
206+
207+
return nil, false, nil
208+
}
209+
210+
// authenticateClient validates client credentials for the token endpoint.
211+
// It first checks dynamically registered clients in the database, then falls back
212+
// to static clients configured in config.yaml.
213+
func (c *Client) authenticateClient(ctx context.Context, clientID, clientSecret string) (*db.Client, error) {
214+
client, isStatic, err := c.getClientByID(ctx, clientID)
164215
if err != nil {
165216
return nil, err
166217
}
167218
if client == nil {
168219
return nil, ErrInvalidClient
169220
}
170221

171-
// Verify client secret using constant-time comparison via hash
172-
secretHash := sha256.Sum256([]byte(clientSecret))
173-
storedHash := sha256.Sum256([]byte(client.ClientSecretHash))
174-
if !hmacEqual(secretHash[:], storedHash[:]) {
175-
return nil, ErrInvalidClient
222+
// Public clients don't require secret verification
223+
if client.TokenEndpointAuthMethod == "none" {
224+
return client, nil
225+
}
226+
227+
// Verify secret based on client type
228+
if isStatic {
229+
// Static clients have plaintext secrets in config
230+
if !verifyPlaintextSecret(clientSecret, client.ClientSecretHash) {
231+
return nil, ErrInvalidClient
232+
}
233+
} else {
234+
// DB clients have bcrypt-hashed secrets
235+
if bcrypt.CompareHashAndPassword([]byte(client.ClientSecretHash), []byte(clientSecret)) != nil {
236+
return nil, ErrInvalidClient
237+
}
176238
}
177239

178240
return client, nil
179241
}
180242

181-
// hmacEqual performs constant-time comparison of two byte slices
182-
func hmacEqual(a, b []byte) bool {
183-
if len(a) != len(b) {
184-
return false
243+
// getOrDefault returns the slice if non-empty, otherwise returns the default value
244+
func getOrDefault(s, defaultVal []string) []string {
245+
if len(s) > 0 {
246+
return s
185247
}
186-
var result byte
187-
for i := 0; i < len(a); i++ {
188-
result |= a[i] ^ b[i]
248+
return defaultVal
249+
}
250+
251+
// getOrDefaultString returns the string if non-empty, otherwise returns the default value
252+
func getOrDefaultString(s, defaultVal string) string {
253+
if s != "" {
254+
return s
189255
}
190-
return result == 0
256+
return defaultVal
191257
}
192258

193259
// createDCQLQuery creates a DCQL query based on the requested scopes

internal/verifier/apiv1/client_test.go

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -177,66 +177,6 @@ func TestPKCE_S256(t *testing.T) {
177177
assert.Equal(t, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge)
178178
}
179179

180-
// TestHmacEqual tests the constant-time byte comparison function
181-
func TestHmacEqual(t *testing.T) {
182-
tests := []struct {
183-
name string
184-
a []byte
185-
b []byte
186-
expected bool
187-
}{
188-
{
189-
name: "equal slices",
190-
a: []byte("hello world"),
191-
b: []byte("hello world"),
192-
expected: true,
193-
},
194-
{
195-
name: "different slices same length",
196-
a: []byte("hello world"),
197-
b: []byte("hello worle"),
198-
expected: false,
199-
},
200-
{
201-
name: "different lengths",
202-
a: []byte("hello"),
203-
b: []byte("hello world"),
204-
expected: false,
205-
},
206-
{
207-
name: "empty slices",
208-
a: []byte{},
209-
b: []byte{},
210-
expected: true,
211-
},
212-
{
213-
name: "one empty one not",
214-
a: []byte{},
215-
b: []byte("hello"),
216-
expected: false,
217-
},
218-
{
219-
name: "binary data equal",
220-
a: []byte{0x00, 0x01, 0x02, 0xff},
221-
b: []byte{0x00, 0x01, 0x02, 0xff},
222-
expected: true,
223-
},
224-
{
225-
name: "binary data different",
226-
a: []byte{0x00, 0x01, 0x02, 0xff},
227-
b: []byte{0x00, 0x01, 0x02, 0xfe},
228-
expected: false,
229-
},
230-
}
231-
232-
for _, tt := range tests {
233-
t.Run(tt.name, func(t *testing.T) {
234-
result := hmacEqual(tt.a, tt.b)
235-
assert.Equal(t, tt.expected, result)
236-
})
237-
}
238-
}
239-
240180
func TestClient_buildLegacyDCQLQuery(t *testing.T) {
241181
tests := []struct {
242182
name string

0 commit comments

Comments
 (0)