Skip to content

Commit 8ca84a9

Browse files
committed
feat(sdk-go): add go client with jwks verification support
- Integrates: replaces Go SDK placeholder with typed client config, RS256 token verification, JWKS cache and refresh logic, typed SDK errors, README usage docs, and Go unit tests for public-key and JWKS validation flows. - Security/Behavior: validates signed tokens with issuer/audience options, retries JWKS lookup once for key-rotation compatibility, and maps invalid/expired/JWKS failures to explicit auth error types. - Validation: attempted gofmt and go test ./... in sdks/go; execution could not run in this workspace because Go tooling is not installed.
1 parent da6dbd0 commit 8ca84a9

8 files changed

Lines changed: 582 additions & 4 deletions

File tree

sdks/go/README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
11
# authkit-go
22

3-
Go SDK source package.
3+
Go SDK for AuthKit token verification with JWKS caching.
4+
5+
## Usage
6+
7+
```go
8+
client, err := authkit.New(authkit.Config{
9+
BaseURL: "https://auth.example.com",
10+
Audience: "project_123",
11+
})
12+
if err != nil {
13+
panic(err)
14+
}
15+
16+
claims, err := client.VerifyToken(context.Background(), token)
17+
if err != nil {
18+
panic(err)
19+
}
20+
21+
fmt.Println(claims["sub"])
22+
```

sdks/go/authkit.go

Lines changed: 0 additions & 3 deletions
This file was deleted.

sdks/go/client.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package authkit
2+
3+
import (
4+
"context"
5+
"crypto/rsa"
6+
"errors"
7+
"fmt"
8+
"net/http"
9+
"strings"
10+
"time"
11+
12+
"github.com/golang-jwt/jwt/v5"
13+
)
14+
15+
const Version = "0.1.0"
16+
17+
// Config configures the AuthKit Go client.
18+
type Config struct {
19+
BaseURL string
20+
PublicKey string
21+
Issuer string
22+
Audience string
23+
HTTPClient *http.Client
24+
JWKSTTL time.Duration
25+
}
26+
27+
// Client verifies AuthKit tokens using a configured public key or JWKS.
28+
type Client struct {
29+
baseURL string
30+
issuer string
31+
audience string
32+
publicKey *rsa.PublicKey
33+
jwksCache *jwksCache
34+
httpClient *http.Client
35+
}
36+
37+
// New constructs a new AuthKit client.
38+
func New(config Config) (*Client, error) {
39+
baseURL := strings.TrimRight(config.BaseURL, "/")
40+
if baseURL == "" {
41+
return nil, fmt.Errorf("%w: base URL is required", ErrInvalidConfig)
42+
}
43+
44+
httpClient := config.HTTPClient
45+
if httpClient == nil {
46+
httpClient = &http.Client{Timeout: 5 * time.Second}
47+
}
48+
49+
var parsedPublicKey *rsa.PublicKey
50+
if config.PublicKey != "" {
51+
key, err := jwt.ParseRSAPublicKeyFromPEM([]byte(config.PublicKey))
52+
if err != nil {
53+
return nil, fmt.Errorf("%w: unable to parse public key: %v", ErrInvalidConfig, err)
54+
}
55+
parsedPublicKey = key
56+
}
57+
58+
cacheTTL := config.JWKSTTL
59+
if cacheTTL <= 0 {
60+
cacheTTL = time.Hour
61+
}
62+
63+
return &Client{
64+
baseURL: baseURL,
65+
issuer: config.Issuer,
66+
audience: config.Audience,
67+
publicKey: parsedPublicKey,
68+
httpClient: httpClient,
69+
jwksCache: newJWKSCache(jwksCacheConfig{
70+
baseURL: baseURL,
71+
httpClient: httpClient,
72+
ttl: cacheTTL,
73+
}),
74+
}, nil
75+
}
76+
77+
// VerifyToken validates a JWT and returns token claims.
78+
func (c *Client) VerifyToken(ctx context.Context, token string) (map[string]any, error) {
79+
if strings.TrimSpace(token) == "" {
80+
return nil, ErrInvalidToken
81+
}
82+
83+
options := []jwt.ParserOption{
84+
jwt.WithValidMethods([]string{"RS256"}),
85+
}
86+
if c.issuer != "" {
87+
options = append(options, jwt.WithIssuer(c.issuer))
88+
}
89+
if c.audience != "" {
90+
options = append(options, jwt.WithAudience(c.audience))
91+
}
92+
93+
claims := jwt.MapClaims{}
94+
keyFunc := func(parsed *jwt.Token) (any, error) {
95+
if c.publicKey != nil {
96+
return c.publicKey, nil
97+
}
98+
99+
kid, err := extractKID(parsed)
100+
if err != nil {
101+
return nil, err
102+
}
103+
104+
key, err := c.jwksCache.GetKey(ctx, kid, false)
105+
if err != nil {
106+
return nil, err
107+
}
108+
if key != nil {
109+
return key, nil
110+
}
111+
112+
// Retry once with forced refresh for key rotation.
113+
key, err = c.jwksCache.GetKey(ctx, kid, true)
114+
if err != nil {
115+
return nil, err
116+
}
117+
if key == nil {
118+
return nil, fmt.Errorf("%w: no matching key for kid %s", ErrInvalidToken, kid)
119+
}
120+
121+
return key, nil
122+
}
123+
124+
_, err := jwt.ParseWithClaims(token, claims, keyFunc, options...)
125+
if err != nil {
126+
switch {
127+
case errors.Is(err, jwt.ErrTokenExpired):
128+
return nil, ErrTokenExpired
129+
case errors.Is(err, ErrInvalidToken):
130+
return nil, err
131+
case errors.Is(err, ErrJWKSFetchFailed):
132+
return nil, err
133+
default:
134+
return nil, fmt.Errorf("%w: %v", ErrInvalidToken, err)
135+
}
136+
}
137+
138+
return claims, nil
139+
}
140+
141+
// CloseIdleConnections closes idle HTTP connections held by the SDK HTTP client.
142+
func (c *Client) CloseIdleConnections() {
143+
if c.httpClient != nil {
144+
c.httpClient.CloseIdleConnections()
145+
}
146+
}
147+
148+
func extractKID(token *jwt.Token) (string, error) {
149+
rawKid, ok := token.Header["kid"]
150+
if !ok {
151+
return "", fmt.Errorf("%w: missing kid header", ErrInvalidToken)
152+
}
153+
154+
kid, ok := rawKid.(string)
155+
if !ok || strings.TrimSpace(kid) == "" {
156+
return "", fmt.Errorf("%w: invalid kid header", ErrInvalidToken)
157+
}
158+
159+
return kid, nil
160+
}

sdks/go/client_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package authkit
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/rsa"
7+
"crypto/x509"
8+
"encoding/pem"
9+
"errors"
10+
"testing"
11+
"time"
12+
13+
"github.com/golang-jwt/jwt/v5"
14+
)
15+
16+
func TestVerifyTokenWithPublicKey(t *testing.T) {
17+
privateKey := generatePrivateKey(t)
18+
publicPEM := encodePublicKeyPEM(t, &privateKey.PublicKey)
19+
20+
client, err := New(Config{
21+
BaseURL: "https://auth.example.com",
22+
PublicKey: publicPEM,
23+
Issuer: "https://auth.example.com",
24+
Audience: "project_1",
25+
})
26+
if err != nil {
27+
t.Fatalf("New() error = %v", err)
28+
}
29+
30+
token := signToken(t, privateKey, "kid_1", time.Now().Add(time.Hour))
31+
claims, err := client.VerifyToken(context.Background(), token)
32+
if err != nil {
33+
t.Fatalf("VerifyToken() error = %v", err)
34+
}
35+
36+
if claims["sub"] != "user_1" {
37+
t.Fatalf("expected sub=user_1, got %v", claims["sub"])
38+
}
39+
}
40+
41+
func TestVerifyTokenExpired(t *testing.T) {
42+
privateKey := generatePrivateKey(t)
43+
publicPEM := encodePublicKeyPEM(t, &privateKey.PublicKey)
44+
45+
client, err := New(Config{
46+
BaseURL: "https://auth.example.com",
47+
PublicKey: publicPEM,
48+
})
49+
if err != nil {
50+
t.Fatalf("New() error = %v", err)
51+
}
52+
53+
token := signToken(t, privateKey, "kid_1", time.Now().Add(-time.Minute))
54+
_, err = client.VerifyToken(context.Background(), token)
55+
if err == nil {
56+
t.Fatalf("expected expired-token error, got nil")
57+
}
58+
if !errors.Is(err, ErrTokenExpired) && err != ErrTokenExpired {
59+
t.Fatalf("expected ErrTokenExpired, got %v", err)
60+
}
61+
}
62+
63+
func generatePrivateKey(t *testing.T) *rsa.PrivateKey {
64+
t.Helper()
65+
66+
key, err := rsa.GenerateKey(rand.Reader, 2048)
67+
if err != nil {
68+
t.Fatalf("GenerateKey() error = %v", err)
69+
}
70+
return key
71+
}
72+
73+
func encodePublicKeyPEM(t *testing.T, publicKey *rsa.PublicKey) string {
74+
t.Helper()
75+
76+
publicBytes := x509.MarshalPKCS1PublicKey(publicKey)
77+
pemBytes := pem.EncodeToMemory(&pem.Block{
78+
Type: "RSA PUBLIC KEY",
79+
Bytes: publicBytes,
80+
})
81+
return string(pemBytes)
82+
}
83+
84+
func signToken(t *testing.T, privateKey *rsa.PrivateKey, kid string, expiresAt time.Time) string {
85+
t.Helper()
86+
87+
claims := jwt.MapClaims{
88+
"sub": "user_1",
89+
"iss": "https://auth.example.com",
90+
"aud": "project_1",
91+
"iat": time.Now().Unix(),
92+
"exp": expiresAt.Unix(),
93+
}
94+
95+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
96+
token.Header["kid"] = kid
97+
98+
signed, err := token.SignedString(privateKey)
99+
if err != nil {
100+
t.Fatalf("SignedString() error = %v", err)
101+
}
102+
return signed
103+
}

sdks/go/errors.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package authkit
2+
3+
import "fmt"
4+
5+
// Error is the typed error returned by AuthKit SDK operations.
6+
type Error struct {
7+
Code string
8+
Message string
9+
StatusCode int
10+
}
11+
12+
func (e *Error) Error() string {
13+
return fmt.Sprintf("%s: %s", e.Code, e.Message)
14+
}
15+
16+
var (
17+
ErrInvalidConfig = &Error{
18+
Code: "INVALID_CONFIGURATION",
19+
Message: "invalid AuthKit configuration",
20+
StatusCode: 400,
21+
}
22+
ErrInvalidToken = &Error{
23+
Code: "INVALID_TOKEN",
24+
Message: "invalid token",
25+
StatusCode: 401,
26+
}
27+
ErrTokenExpired = &Error{
28+
Code: "TOKEN_EXPIRED",
29+
Message: "token has expired",
30+
StatusCode: 401,
31+
}
32+
ErrJWKSFetchFailed = &Error{
33+
Code: "JWKS_FETCH_FAILED",
34+
Message: "failed to fetch JWKS",
35+
StatusCode: 502,
36+
}
37+
)

sdks/go/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
module github.com/authkit/authkit-go
22

33
go 1.21
4+
5+
require github.com/golang-jwt/jwt/v5 v5.2.1

0 commit comments

Comments
 (0)