Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
294 lines
9.2 KiB
Go
294 lines
9.2 KiB
Go
package deezer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("JWT Authentication", func() {
|
|
var httpClient *fakeHttpClient
|
|
var client *client
|
|
var ctx context.Context
|
|
|
|
BeforeEach(func() {
|
|
httpClient = &fakeHttpClient{}
|
|
client = newClient(httpClient, "en")
|
|
ctx = context.Background()
|
|
})
|
|
|
|
Describe("getJWT", func() {
|
|
Context("with a valid JWT response", func() {
|
|
It("successfully fetches and caches a JWT token", func() {
|
|
testJWT := createTestJWT(5 * time.Minute)
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
|
})
|
|
|
|
token, err := client.getJWT(ctx)
|
|
Expect(err).To(BeNil())
|
|
Expect(token).To(Equal(testJWT))
|
|
})
|
|
|
|
It("returns the cached token on subsequent calls", func() {
|
|
testJWT := createTestJWT(5 * time.Minute)
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
|
})
|
|
|
|
// First call should fetch from API
|
|
token1, err := client.getJWT(ctx)
|
|
Expect(err).To(BeNil())
|
|
Expect(token1).To(Equal(testJWT))
|
|
Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous"))
|
|
|
|
// Second call should return cached token without hitting API
|
|
httpClient.lastRequest = nil // Clear last request to verify no new request is made
|
|
token2, err := client.getJWT(ctx)
|
|
Expect(err).To(BeNil())
|
|
Expect(token2).To(Equal(testJWT))
|
|
Expect(httpClient.lastRequest).To(BeNil()) // No new request made
|
|
})
|
|
|
|
It("parses the JWT expiration time correctly", func() {
|
|
expectedExpiration := time.Now().Add(5 * time.Minute)
|
|
testToken, err := jwt.NewBuilder().
|
|
Expiration(expectedExpiration).
|
|
Build()
|
|
Expect(err).To(BeNil())
|
|
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
|
Expect(err).To(BeNil())
|
|
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
|
|
})
|
|
|
|
token, err := client.getJWT(ctx)
|
|
Expect(err).To(BeNil())
|
|
Expect(token).ToNot(BeEmpty())
|
|
|
|
// Verify the token is cached until close to expiration
|
|
// The cache should expire 1 minute before the JWT expires
|
|
expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute)
|
|
Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second))
|
|
})
|
|
})
|
|
|
|
Context("with JWT tokens that expire soon", func() {
|
|
It("rejects tokens that expire in less than 1 minute", func() {
|
|
// Create a token that expires in 30 seconds (less than 1-minute buffer)
|
|
testJWT := createTestJWT(30 * time.Second)
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
|
})
|
|
|
|
_, err := client.getJWT(ctx)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
|
|
})
|
|
|
|
It("rejects already expired tokens", func() {
|
|
// Create a token that expired 1 minute ago
|
|
testJWT := createTestJWT(-1 * time.Minute)
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
|
})
|
|
|
|
_, err := client.getJWT(ctx)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
|
|
})
|
|
|
|
It("accepts tokens that expire in more than 1 minute", func() {
|
|
// Create a token that expires in 2 minutes (just over the 1-minute buffer)
|
|
testJWT := createTestJWT(2 * time.Minute)
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
|
})
|
|
|
|
token, err := client.getJWT(ctx)
|
|
Expect(err).To(BeNil())
|
|
Expect(token).ToNot(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Context("with invalid responses", func() {
|
|
It("handles HTTP error responses", func() {
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 500,
|
|
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
|
|
})
|
|
|
|
_, err := client.getJWT(ctx)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("failed to get JWT token"))
|
|
})
|
|
|
|
It("handles malformed JSON responses", func() {
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)),
|
|
})
|
|
|
|
_, err := client.getJWT(ctx)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("failed to parse auth response"))
|
|
})
|
|
|
|
It("handles responses with empty JWT field", func() {
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)),
|
|
})
|
|
|
|
_, err := client.getJWT(ctx)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(Equal("deezer: no JWT token in response"))
|
|
})
|
|
|
|
It("handles invalid JWT tokens", func() {
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)),
|
|
})
|
|
|
|
_, err := client.getJWT(ctx)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("failed to parse JWT token"))
|
|
})
|
|
|
|
It("rejects JWT tokens without expiration", func() {
|
|
// Create a JWT without expiration claim
|
|
testToken, err := jwt.NewBuilder().
|
|
Claim("custom", "value").
|
|
Build()
|
|
Expect(err).To(BeNil())
|
|
|
|
// Verify token has no expiration
|
|
Expect(testToken.Expiration().IsZero()).To(BeTrue())
|
|
|
|
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
|
Expect(err).To(BeNil())
|
|
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
|
|
})
|
|
|
|
_, err = client.getJWT(ctx)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time"))
|
|
})
|
|
})
|
|
|
|
Context("token caching behavior", func() {
|
|
It("fetches a new token when the cached token expires", func() {
|
|
// First token expires in 5 minutes
|
|
firstJWT := createTestJWT(5 * time.Minute)
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))),
|
|
})
|
|
|
|
token1, err := client.getJWT(ctx)
|
|
Expect(err).To(BeNil())
|
|
Expect(token1).To(Equal(firstJWT))
|
|
|
|
// Manually expire the cached token
|
|
client.jwt.expiresAt = time.Now().Add(-1 * time.Second)
|
|
|
|
// Second token with different expiration (10 minutes)
|
|
secondJWT := createTestJWT(10 * time.Minute)
|
|
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))),
|
|
})
|
|
|
|
token2, err := client.getJWT(ctx)
|
|
Expect(err).To(BeNil())
|
|
Expect(token2).To(Equal(secondJWT))
|
|
Expect(token2).ToNot(Equal(token1))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("jwtToken cache", func() {
|
|
var cache *jwtToken
|
|
|
|
BeforeEach(func() {
|
|
cache = &jwtToken{}
|
|
})
|
|
|
|
It("returns false for expired tokens", func() {
|
|
cache.set("test-token", -1*time.Second) // Already expired
|
|
token, valid := cache.get()
|
|
Expect(valid).To(BeFalse())
|
|
Expect(token).To(BeEmpty())
|
|
})
|
|
|
|
It("returns true for valid tokens", func() {
|
|
cache.set("test-token", 4*time.Minute)
|
|
token, valid := cache.get()
|
|
Expect(valid).To(BeTrue())
|
|
Expect(token).To(Equal("test-token"))
|
|
})
|
|
|
|
It("is thread-safe for concurrent access", func() {
|
|
wg := sync.WaitGroup{}
|
|
|
|
// Writer goroutine
|
|
wg.Go(func() {
|
|
for i := 0; i < 100; i++ {
|
|
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
|
|
time.Sleep(1 * time.Millisecond)
|
|
}
|
|
})
|
|
|
|
// Reader goroutine
|
|
wg.Go(func() {
|
|
for i := 0; i < 100; i++ {
|
|
cache.get()
|
|
time.Sleep(1 * time.Millisecond)
|
|
}
|
|
})
|
|
|
|
// Wait for both goroutines to complete
|
|
wg.Wait()
|
|
|
|
// Verify final state is valid
|
|
token, valid := cache.get()
|
|
Expect(valid).To(BeTrue())
|
|
Expect(token).To(HavePrefix("token-"))
|
|
})
|
|
})
|
|
})
|
|
|
|
// createTestJWT creates a valid JWT token for testing purposes
|
|
func createTestJWT(expiresIn time.Duration) string {
|
|
token, err := jwt.NewBuilder().
|
|
Expiration(time.Now().Add(expiresIn)).
|
|
Build()
|
|
if err != nil {
|
|
panic(fmt.Sprintf("failed to create test JWT: %v", err))
|
|
}
|
|
signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature())
|
|
if err != nil {
|
|
panic(fmt.Sprintf("failed to sign test JWT: %v", err))
|
|
}
|
|
return string(signed)
|
|
}
|