Skip to content
Closed
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ oidc: {
clientSecret: "your-client-secret" // Optional, omit for a public client
// scopes: ["openid", "profile", "email"] // Optional, default shown
// authMethod: "basic" // Optional, "basic", "post" or "none" (public client), default is "basic"
// audience: "https://api.example.com" // Optional, sent as "audience" to the token endpoint
}

// Optional: For Resource Owner Password Credentials flow
Expand Down Expand Up @@ -81,6 +82,22 @@ user: {
}
```

### Auth0

Auth0 needs an `audience` (the API identifier) to issue a JWT access token.
It is sent with both the Client Credentials and the Password flows.

```cue
package config

oidc: {
issuerUrl: "https://my-tenant.eu.auth0.com/"
clientId: "my-client"
clientSecret: "my-secret"
audience: "https://api.example.com"
}
```

### Keycloak (public client)

For a Keycloak client with *Client authentication* turned off, omit `clientSecret`
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type OIDCConfig struct {
ClientSecret string `json:"clientSecret"`
Scopes []string `json:"scopes"`
AuthMethod string `json:"authMethod"`
Audience string `json:"audience,omitempty"`
}

type UserConfig struct {
Expand Down
27 changes: 27 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,30 @@ oidc: {
t.Errorf("expected authMethod 'none', got %q", cfg.OIDC.AuthMethod)
}
}

func TestLoad_Audience(t *testing.T) {
content := `
package config

oidc: {
issuerUrl: "https://example.com"
clientId: "client"
clientSecret: "secret"
audience: "https://api.example.com"
}
`
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "authk_audience.cue")
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}

cfg, err := Load(configFile)
if err != nil {
t.Fatalf("Load() error = %v", err)
}

if cfg.OIDC.Audience != "https://api.example.com" {
t.Errorf("expected audience 'https://api.example.com', got %q", cfg.OIDC.Audience)
}
}
1 change: 1 addition & 0 deletions internal/config/schema.cue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ oidc: {
clientSecret: string | *""
scopes: [...string] | *["openid", "profile", "email"]
authMethod: "basic" | "post" | "none" | *"basic"
audience?: string
}
user: {
username?: string
Expand Down
100 changes: 99 additions & 1 deletion internal/oidc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package oidc

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

"github.com/codozor/authk/internal/config"
Expand All @@ -18,6 +22,7 @@ type Client struct {
cfg *config.Config
provider *oidc.Provider
oauth2Config *oauth2.Config
httpClient *http.Client
public bool
}

Expand Down Expand Up @@ -79,6 +84,7 @@ func NewClient(cfg *config.Config) (*Client, error) {
cfg: cfg,
provider: provider,
oauth2Config: oauth2Config,
httpClient: httpClient,
public: public,
}, nil
}
Expand All @@ -101,7 +107,12 @@ func (c *Client) GetToken(username, password string) (*oauth2.Token, error) {

if user != "" && pass != "" {
log.Info().Str("grant_type", "password").Msg("Using Resource Owner Password Credentials flow")
token, err = c.oauth2Config.PasswordCredentialsToken(ctx, user, pass)
if c.cfg.OIDC.Audience != "" {
// oauth2.Config.PasswordCredentialsToken does not accept extra parameters
token, err = c.passwordCredentialsToken(ctx, user, pass)
} else {
token, err = c.oauth2Config.PasswordCredentialsToken(ctx, user, pass)
}
} else if c.public {
return nil, fmt.Errorf("public client (no client secret) requires username and password: client_credentials flow is not available")
} else {
Expand All @@ -114,6 +125,9 @@ func (c *Client) GetToken(username, password string) (*oauth2.Token, error) {
Scopes: c.oauth2Config.Scopes,
AuthStyle: c.oauth2Config.Endpoint.AuthStyle,
}
if c.cfg.OIDC.Audience != "" {
ccConfig.EndpointParams = url.Values{"audience": {c.cfg.OIDC.Audience}}
}
// The clientcredentials.Config should use the http client set in the context
token, err = ccConfig.Token(ctx)
}
Expand Down Expand Up @@ -153,3 +167,87 @@ func (c *Client) RefreshToken(oldToken *oauth2.Token) (*oauth2.Token, error) {
}
return newToken, nil
}

// passwordCredentialsToken performs the Resource Owner Password Credentials grant
// with the configured audience, which oauth2.Config.PasswordCredentialsToken cannot send.
func (c *Client) passwordCredentialsToken(ctx context.Context, username, password string) (*oauth2.Token, error) {
v := url.Values{
"grant_type": {"password"},
"username": {username},
"password": {password},
"audience": {c.cfg.OIDC.Audience},
}
if len(c.oauth2Config.Scopes) > 0 {
v.Set("scope", strings.Join(c.oauth2Config.Scopes, " "))
}
if c.oauth2Config.Endpoint.AuthStyle == oauth2.AuthStyleInParams {
v.Set("client_id", c.oauth2Config.ClientID)
if c.oauth2Config.ClientSecret != "" {
v.Set("client_secret", c.oauth2Config.ClientSecret)
}
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.oauth2Config.Endpoint.TokenURL, strings.NewReader(v.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
if c.oauth2Config.Endpoint.AuthStyle == oauth2.AuthStyleInHeader {
// RFC 6749 section 2.3.1: credentials are form-urlencoded before Basic encoding
req.SetBasicAuth(url.QueryEscape(c.oauth2Config.ClientID), url.QueryEscape(c.oauth2Config.ClientSecret))
}

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
retrieveErr := &oauth2.RetrieveError{Response: resp, Body: body}
var errResp struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
ErrorURI string `json:"error_uri"`
}
if json.Unmarshal(body, &errResp) == nil {
retrieveErr.ErrorCode = errResp.Error
retrieveErr.ErrorDescription = errResp.ErrorDescription
retrieveErr.ErrorURI = errResp.ErrorURI
}
return nil, retrieveErr
}

var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token"`
ExpiresIn json.Number `json:"expires_in"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to decode token response: %w", err)
}
if tokenResp.AccessToken == "" {
return nil, fmt.Errorf("server response missing access_token")
}
var raw map[string]interface{}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("failed to decode token response: %w", err)
}

token := &oauth2.Token{
AccessToken: tokenResp.AccessToken,
TokenType: tokenResp.TokenType,
RefreshToken: tokenResp.RefreshToken,
}
if expiresIn, err := tokenResp.ExpiresIn.Int64(); err == nil && expiresIn > 0 {
token.Expiry = time.Now().Add(time.Duration(expiresIn) * time.Second)
token.ExpiresIn = expiresIn
}
return token.WithExtra(raw), nil
}
127 changes: 127 additions & 0 deletions internal/oidc/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package oidc
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -383,3 +384,129 @@ func TestClient_GetToken_PublicClient(t *testing.T) {
})
}
}

func TestClient_GetToken_Audience(t *testing.T) {
// Mock OIDC Provider
var testServer *httptest.Server
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"issuer": testServer.URL,
"token_endpoint": testServer.URL + "/token",
"jwks_uri": testServer.URL + "/certs",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
}); err != nil {
t.Error(err)
}
case "/token":
if err := r.ParseForm(); err != nil {
t.Error(err)
}
if r.PostForm.Get("audience") != "https://api.example.com" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"access_denied","error_description":"missing audience"}`))
return
}
// Client authentication must follow the configured auth method
clientID, clientSecret, basic := r.BasicAuth()
if !basic {
clientID, clientSecret = r.PostForm.Get("client_id"), r.PostForm.Get("client_secret")
}
w.Header().Set("Content-Type", "application/json")
resp := mockTokenResponse{
AccessToken: r.Form.Get("grant_type") + ":" + clientID + ":" + clientSecret + ":" + r.PostForm.Get("scope"),
ExpiresIn: 3600,
TokenType: "Bearer",
RefreshToken: "mock_refresh_token",
IDToken: "not-a-jwt",
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
t.Error(err)
}
default:
w.WriteHeader(http.StatusNotFound)
}
})
testServer = httptest.NewServer(handler)
defer testServer.Close()

tests := []struct {
name string
clientSecret string
authMethod string
username string
want string
}{
{"client_credentials", "secret", "basic", "", "client_credentials:client:secret:openid profile"},
{"password basic", "secret", "basic", "testuser", "password:client:secret:openid profile"},
{"password post", "secret", "post", "testuser", "password:client:secret:openid profile"},
{"password public", "", "none", "testuser", "password:client::openid profile"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{
OIDC: config.OIDCConfig{
IssuerURL: testServer.URL,
ClientID: "client",
ClientSecret: tt.clientSecret,
AuthMethod: tt.authMethod,
Scopes: []string{"openid", "profile"},
Audience: "https://api.example.com",
},
}

client, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}

password := ""
if tt.username != "" {
password = "testpass"
}
token, err := client.GetToken(tt.username, password)
if err != nil {
t.Fatalf("GetToken() error = %v", err)
}
if token.AccessToken != tt.want {
t.Errorf("expected access token %q, got %q", tt.want, token.AccessToken)
}
if token.TokenType != "Bearer" {
t.Errorf("expected token type 'Bearer', got %s", token.TokenType)
}
if token.Expiry.IsZero() {
t.Error("expected token expiry to be set")
}
if token.Extra("id_token") != "not-a-jwt" {
t.Errorf("expected id_token in extra, got %v", token.Extra("id_token"))
}
})
}

t.Run("error response", func(t *testing.T) {
cfg := &config.Config{
OIDC: config.OIDCConfig{
IssuerURL: testServer.URL,
ClientID: "client",
ClientSecret: "secret",
Audience: "https://other.example.com",
},
}
client, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
_, err = client.GetToken("testuser", "testpass")
var retrieveErr *oauth2.RetrieveError
if !errors.As(err, &retrieveErr) {
t.Fatalf("expected *oauth2.RetrieveError, got %v", err)
}
if retrieveErr.ErrorCode != "access_denied" {
t.Errorf("expected error code 'access_denied', got %q", retrieveErr.ErrorCode)
}
})
}
Loading