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
20 changes: 9 additions & 11 deletions authn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ JWT-based authentication. Validates tokens against a JWKS endpoint, extracts ide
import "github.com/OpenNSW/core/authn"

manager, err := authn.NewManager(userProfileSvc, authn.Config{
JWKSURL: "https://idp.example.com/.well-known/jwks.json",
Issuer: "https://idp.example.com",
Audience: "my-api",
ClientIDs: []string{"my-m2m-client"},
JWKSURL: "https://idp.example.com/.well-known/jwks.json",
Issuer: "https://idp.example.com",
Audience: "my-api",

// Optional: declare any claims beyond the fixed schema you need. See
// "Extra claims" below.
Expand All @@ -28,14 +27,14 @@ authn:
jwksURL: https://idp.example.com/.well-known/jwks.json
issuer: https://idp.example.com
audience: my-api
clientIDs:
- my-m2m-client
userClaims:
optional: [email, ouId, ouHandle]
```

> **Note:** every token must carry a `grant_type` claim — it decides user vs. client principal, and a token without it is rejected with `unsupported grant type`. This is not a standard access-token claim (RFC 9068 does not define it), so an IdP that omits it will not work with this package as-is.

> **Note:** `audience` is what binds a token to this service: any client the IdP issues a token for this audience is accepted. Which clients may call the service is authorization policy, not this package's concern. For client-credentials tokens, restrict it in authz or the application using `ClientPrincipal.ClientID`. User (`authorization_code`) tokens produce a `UserPrincipal` that does not carry the originating client ID, so restricting those by client must be done another way (e.g. IdP-side policy or a dedicated audience per client group). If your IdP sets `aud` to the requesting client rather than the API, configure an audience / API resource for the service in the IdP.

## Middleware

```go
Expand Down Expand Up @@ -82,10 +81,9 @@ email := authn.GetAuthContext(r.Context()).ExtraClaims().String("email")

```go
manager, err := authn.NewManager(userProfileSvc, authn.Config{
JWKSURL: "...",
Issuer: "...",
Audience: "...",
ClientIDs: []string{"my-m2m-client"},
JWKSURL: "...",
Issuer: "...",
Audience: "...",

UserClaims: authn.ClaimSpec{
Optional: []string{"email", "ouId", "ouHandle", "given_name"},
Expand All @@ -100,7 +98,7 @@ manager, err := authn.NewManager(userProfileSvc, authn.Config{
Or, constructing a `TokenExtractor` directly:

```go
extractor, err := authn.NewTokenExtractor(jwksURL, issuer, audience, clientIDs,
extractor, err := authn.NewTokenExtractor(jwksURL, issuer, audience,
authn.WithUserClaims(authn.ClaimSpec{
Optional: []string{"email", "ouId", "ouHandle"},
Required: []string{"phone_number"},
Expand Down
13 changes: 4 additions & 9 deletions authn/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@ import (
// application config struct and populated generically (e.g. via
// yaml.Unmarshal).
type Config struct {
JWKSURL string `yaml:"jwksURL"`
Issuer string `yaml:"issuer"`
Audience string `yaml:"audience"`
ClientIDs []string `yaml:"clientIDs"`
InsecureSkipTLSVerify bool `yaml:"insecureSkipTLSVerify"`
JWKSURL string `yaml:"jwksURL"`
Issuer string `yaml:"issuer"`
Audience string `yaml:"audience"`
InsecureSkipTLSVerify bool `yaml:"insecureSkipTLSVerify"`

// UserClaims declares extra JWT claims (beyond authn's fixed schema, e.g.
// "email", "phone_number", "ouId", "ouHandle", "given_name") to extract
Expand Down Expand Up @@ -50,10 +49,6 @@ func (c Config) Validate() error {
return fmt.Errorf("audience is required")
}

if len(c.ClientIDs) == 0 {
return fmt.Errorf("clientIDs is required")
}

if err := validateRolesClaimName(c.rolesClaim()); err != nil {
return err
}
Expand Down
23 changes: 8 additions & 15 deletions authn/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ func TestConfigValidate(t *testing.T) {
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{
"TRADER_PORTAL_APP",
},
}

// Each error names the setting by its yaml key, so a config file author
Expand All @@ -25,12 +22,11 @@ func TestConfigValidate(t *testing.T) {
wantErr string
}{
{name: "valid config", config: valid},
{name: "missing jwks url", config: Config{Issuer: valid.Issuer, Audience: valid.Audience, ClientIDs: valid.ClientIDs}, wantErr: "jwksURL is required"},
{name: "invalid jwks url", config: Config{JWKSURL: "jwks", Issuer: valid.Issuer, Audience: valid.Audience, ClientIDs: valid.ClientIDs}, wantErr: "jwksURL must be a valid absolute URL"},
{name: "missing issuer", config: Config{JWKSURL: valid.JWKSURL, Audience: valid.Audience, ClientIDs: valid.ClientIDs}, wantErr: "issuer is required"},
{name: "invalid issuer", config: Config{JWKSURL: valid.JWKSURL, Issuer: "ftp://localhost", Audience: valid.Audience, ClientIDs: valid.ClientIDs}, wantErr: "issuer must use http or https"},
{name: "missing audience", config: Config{JWKSURL: valid.JWKSURL, Issuer: valid.Issuer, ClientIDs: valid.ClientIDs}, wantErr: "audience is required"},
{name: "missing client ids", config: Config{JWKSURL: valid.JWKSURL, Issuer: valid.Issuer, Audience: valid.Audience}, wantErr: "clientIDs is required"},
{name: "missing jwks url", config: Config{Issuer: valid.Issuer, Audience: valid.Audience}, wantErr: "jwksURL is required"},
{name: "invalid jwks url", config: Config{JWKSURL: "jwks", Issuer: valid.Issuer, Audience: valid.Audience}, wantErr: "jwksURL must be a valid absolute URL"},
{name: "missing issuer", config: Config{JWKSURL: valid.JWKSURL, Audience: valid.Audience}, wantErr: "issuer is required"},
{name: "invalid issuer", config: Config{JWKSURL: valid.JWKSURL, Issuer: "ftp://localhost", Audience: valid.Audience}, wantErr: "issuer must use http or https"},
{name: "missing audience", config: Config{JWKSURL: valid.JWKSURL, Issuer: valid.Issuer}, wantErr: "audience is required"},
}

for _, tt := range tests {
Expand All @@ -51,10 +47,9 @@ func TestConfigValidate(t *testing.T) {

func TestConfigValidate_RejectsFixedSchemaExtraClaim(t *testing.T) {
valid := Config{
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{"TRADER_PORTAL_APP"},
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
}

tests := []struct {
Expand Down Expand Up @@ -97,7 +92,6 @@ func TestConfigValidate_AllowsFormerlyFixedClaims(t *testing.T) {
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{"TRADER_PORTAL_APP"},
UserClaims: ClaimSpec{Optional: []string{"email", "phone_number", "ouId", "ouHandle", "given_name"}},
}
if err := cfg.Validate(); err != nil {
Expand All @@ -113,7 +107,6 @@ func TestConfigValidate_AllowsNamespacedClaimNames(t *testing.T) {
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{"TRADER_PORTAL_APP"},
RolesClaim: "https://app.example.com/roles",
UserClaims: ClaimSpec{Optional: []string{"https://app.example.com/department"}},
}
Expand Down
12 changes: 6 additions & 6 deletions authn/extra_claims_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,19 +452,19 @@ func TestNewTokenExtractor_RejectsFixedSchemaClaimNames(t *testing.T) {
}
for _, name := range names {
t.Run(name+"/optional", func(t *testing.T) {
if _, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, []string{testClientID}, WithUserClaims(ClaimSpec{Optional: []string{name}})); err == nil {
if _, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, WithUserClaims(ClaimSpec{Optional: []string{name}})); err == nil {
t.Fatalf("expected error declaring fixed-schema claim %q", name)
}
})
t.Run(name+"/required", func(t *testing.T) {
if _, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, []string{testClientID}, WithUserClaims(ClaimSpec{Required: []string{name}})); err == nil {
if _, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, WithUserClaims(ClaimSpec{Required: []string{name}})); err == nil {
t.Fatalf("expected error declaring fixed-schema claim %q", name)
}
})
}

t.Run("client variant is also rejected", func(t *testing.T) {
if _, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, []string{testClientID}, WithClientClaims(ClaimSpec{Required: []string{"scope"}})); err == nil {
if _, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, WithClientClaims(ClaimSpec{Required: []string{"scope"}})); err == nil {
t.Fatalf("expected error declaring fixed-schema claim via client option")
}
})
Expand All @@ -473,15 +473,15 @@ func TestNewTokenExtractor_RejectsFixedSchemaClaimNames(t *testing.T) {
// The natural result of strings.Split on an env var with a trailing
// comma. Dropping it silently means an enforcement rule the caller
// asked for never applies.
if _, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, []string{testClientID}, WithUserClaims(ClaimSpec{Required: []string{"email", ""}})); err == nil {
if _, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, WithUserClaims(ClaimSpec{Required: []string{"email", ""}})); err == nil {
t.Fatalf("expected error for a blank claim name")
}
})

t.Run("collision error names every offender deterministically", func(t *testing.T) {
var first string
for i := range 8 {
_, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, []string{testClientID},
_, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID,
WithUserClaims(ClaimSpec{Optional: []string{"scope", "roles", "sub"}}))
if err == nil {
t.Fatalf("expected error")
Expand All @@ -508,7 +508,7 @@ func TestNewTokenExtractor_RejectsFixedSchemaClaimNames(t *testing.T) {
func TestNewTokenExtractor_AllowsFormerlyFixedClaimNames(t *testing.T) {
for _, name := range []string{"email", "phone_number", "ouId", "ouHandle", "given_name"} {
t.Run(name, func(t *testing.T) {
if _, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, []string{testClientID}, WithUserClaims(ClaimSpec{Optional: []string{name}})); err != nil {
if _, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, WithUserClaims(ClaimSpec{Optional: []string{name}})); err != nil {
t.Fatalf("expected %q to be a legitimate extra claim, got error: %v", name, err)
}
})
Expand Down
2 changes: 1 addition & 1 deletion authn/fixed_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ func TestRolesClaim(t *testing.T) {

func TestRolesClaim_NameValidation(t *testing.T) {
construct := func(opts ...Option) error {
_, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, []string{testClientID}, opts...)
_, err := NewTokenExtractor("https://localhost/jwks", testIssuer, testClientID, opts...)
return err
}

Expand Down
2 changes: 1 addition & 1 deletion authn/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func NewManager(userProfileService UserProfileService, authConfig Config) (*Mana
}

tokenExtractor, err := NewTokenExtractorWithClient(
authConfig.JWKSURL, authConfig.Issuer, authConfig.Audience, authConfig.ClientIDs, httpClient,
authConfig.JWKSURL, authConfig.Issuer, authConfig.Audience, httpClient,
buildClaimOptions(authConfig)...,
)
if err != nil {
Expand Down
25 changes: 3 additions & 22 deletions authn/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ func TestNewManager_AllowsNilUserProfileService(t *testing.T) {
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{
"TRADER_PORTAL_APP",
},
}

manager, err := NewManager(nil, cfg)
Expand All @@ -42,9 +39,6 @@ func TestNewManager_InvalidConfig(t *testing.T) {
cfg := Config{
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{
"TRADER_PORTAL_APP",
},
}

if _, err := NewManager(nil, cfg); err == nil {
Expand All @@ -64,7 +58,6 @@ func TestNewManager_InsecureSkipTLSVerify(t *testing.T) {
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{"TRADER_PORTAL_APP"},
InsecureSkipTLSVerify: true,
}

Expand All @@ -89,9 +82,6 @@ func TestManager_Health_Success(t *testing.T) {
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{
"TRADER_PORTAL_APP",
},
}

manager, err := NewManager(nil, cfg)
Expand All @@ -108,9 +98,6 @@ func TestManager_MiddlewareFunctions(t *testing.T) {
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{
"TRADER_PORTAL_APP",
},
}

manager, err := NewManager(nil, cfg)
Expand Down Expand Up @@ -140,9 +127,6 @@ func TestManager_RequireAuthMiddleware(t *testing.T) {
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{
"TRADER_PORTAL_APP",
},
}

manager, err := NewManager(nil, cfg)
Expand Down Expand Up @@ -177,10 +161,9 @@ func TestManager_Close(t *testing.T) {

func TestNewManager_WiresExtraClaims(t *testing.T) {
cfg := Config{
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{"TRADER_PORTAL_APP"},
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
// "email" appears in both slices: required must win on the Config
// path too, independently of the order buildClaimOptions emits.
UserClaims: ClaimSpec{Optional: []string{"email", "given_name"}, Required: []string{"ouHandle", "email"}},
Expand Down Expand Up @@ -223,7 +206,6 @@ func TestNewManager_RejectsFixedSchemaExtraClaim(t *testing.T) {
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{"TRADER_PORTAL_APP"},
UserClaims: ClaimSpec{Optional: []string{"scope"}},
}
if _, err := NewManager(nil, cfg); err == nil {
Expand All @@ -240,7 +222,6 @@ func TestNewManager_RejectsRolesClaimCollision(t *testing.T) {
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
ClientIDs: []string{"TRADER_PORTAL_APP"},
RolesClaim: "groups",
UserClaims: ClaimSpec{Optional: []string{"groups"}},
}
Expand Down
2 changes: 1 addition & 1 deletion authn/test_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func newTokenExtractorWithOptions(t *testing.T, opts ...Option) (*TokenExtractor
})
}))

extractor, err := NewTokenExtractor(jwksServer.URL, testIssuer, testClientID, []string{testClientID}, opts...)
extractor, err := NewTokenExtractor(jwksServer.URL, testIssuer, testClientID, opts...)
if err != nil {
jwksServer.Close()
t.Fatalf("failed to create token extractor: %v", err)
Expand Down
29 changes: 10 additions & 19 deletions authn/token_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"fmt"
"log/slog"
"net/http"
"slices"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -112,11 +111,10 @@ const defaultJWKSCacheTTL = 5 * time.Minute
// It validates JWT signatures using JWKS and resolves a user principal
// or client principal based on grant type.
type TokenExtractor struct {
jwksURL string
expIssuer string
expAudience string
expClientIDs []string
httpClient *http.Client
jwksURL string
expIssuer string
expAudience string
httpClient *http.Client

// userClaims / clientClaims are the raw declarations accumulated from
// WithUserClaims / WithClientClaims. They are validated and flattened into
Expand All @@ -137,25 +135,24 @@ type TokenExtractor struct {
jwksCacheTTL time.Duration
}

func NewTokenExtractor(jwksURL, issuer, audience string, expectedClientIDs []string, opts ...Option) (*TokenExtractor, error) {
return newExtractor(jwksURL, issuer, audience, expectedClientIDs, &http.Client{Timeout: 10 * time.Second}, opts...)
func NewTokenExtractor(jwksURL, issuer, audience string, opts ...Option) (*TokenExtractor, error) {
return newExtractor(jwksURL, issuer, audience, &http.Client{Timeout: 10 * time.Second}, opts...)
}

func NewTokenExtractorWithClient(jwksURL, issuer, audience string, expectedClientIDs []string, httpClient *http.Client, opts ...Option) (*TokenExtractor, error) {
func NewTokenExtractorWithClient(jwksURL, issuer, audience string, httpClient *http.Client, opts ...Option) (*TokenExtractor, error) {
if httpClient == nil {
return NewTokenExtractor(jwksURL, issuer, audience, expectedClientIDs, opts...)
return NewTokenExtractor(jwksURL, issuer, audience, opts...)
}
return newExtractor(jwksURL, issuer, audience, expectedClientIDs, httpClient, opts...)
return newExtractor(jwksURL, issuer, audience, httpClient, opts...)
}

// newExtractor is the single construction path, so a defaulted field
// (rolesClaim) can only be forgotten in one place.
func newExtractor(jwksURL, issuer, audience string, expectedClientIDs []string, httpClient *http.Client, opts ...Option) (*TokenExtractor, error) {
func newExtractor(jwksURL, issuer, audience string, httpClient *http.Client, opts ...Option) (*TokenExtractor, error) {
extractor := &TokenExtractor{
jwksURL: strings.TrimSpace(jwksURL),
expIssuer: strings.TrimSpace(issuer),
expAudience: strings.TrimSpace(audience),
expClientIDs: expectedClientIDs,
jwksCacheTTL: defaultJWKSCacheTTL,
httpClient: httpClient,
rolesClaim: defaultRolesClaim,
Expand All @@ -182,9 +179,6 @@ func (te *TokenExtractor) validateConfig() error {
if te.expAudience == "" {
return fmt.Errorf("audience is not configured")
}
if len(te.expClientIDs) == 0 {
return fmt.Errorf("client ids are not configured")
}
if te.httpClient == nil {
return fmt.Errorf("http client is not configured")
}
Expand Down Expand Up @@ -255,9 +249,6 @@ func (te *TokenExtractor) ExtractPrincipalFromHeader(authHeader string) (*Princi
if claims.ClientID == "" {
return nil, fmt.Errorf("jwt missing client_id claim")
}
if !slices.Contains(te.expClientIDs, claims.ClientID) {
return nil, fmt.Errorf("unexpected client_id claim: %q", claims.ClientID)
}

switch claims.GrantType {
case AuthorizationCodeGrant:
Expand Down
Loading
Loading