* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
141 lines
4.6 KiB
Go
141 lines
4.6 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func useTestSessionSecret(t *testing.T) {
|
|
t.Helper()
|
|
previous := common.SessionSecret
|
|
common.SessionSecret = "test-session-secret-with-sufficient-entropy"
|
|
t.Cleanup(func() { common.SessionSecret = previous })
|
|
}
|
|
|
|
func TestAccessTokenRoundTripAndPurposeIsolation(t *testing.T) {
|
|
useTestSessionSecret(t)
|
|
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2}
|
|
|
|
token, expiresAt, err := IssueAccessToken(identity)
|
|
require.NoError(t, err)
|
|
assert.Positive(t, expiresAt)
|
|
|
|
parsed, err := ParseAccessToken(token)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, identity, parsed)
|
|
|
|
proof, _, err := IssueSecurityProof(identity, "2fa", []string{"channel.key.read"})
|
|
require.NoError(t, err)
|
|
_, err = ParseAccessToken(proof)
|
|
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
|
|
}
|
|
|
|
func TestAccessTokenRejectsTampering(t *testing.T) {
|
|
useTestSessionSecret(t)
|
|
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 1, SessionVersion: 1}
|
|
token, _, err := IssueAccessToken(identity)
|
|
require.NoError(t, err)
|
|
|
|
tamperAt := len(token) - 2
|
|
replacement := "x"
|
|
if token[tamperAt] == 'x' {
|
|
replacement = "y"
|
|
}
|
|
tampered := token[:tamperAt] + replacement + token[tamperAt+1:]
|
|
_, err = ParseAccessToken(tampered)
|
|
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
|
|
|
|
_, internal, err := ParseDashboardAccessToken(tampered)
|
|
assert.True(t, internal)
|
|
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
|
|
}
|
|
|
|
func TestDashboardAccessTokenClassification(t *testing.T) {
|
|
useTestSessionSecret(t)
|
|
|
|
identity, internal, err := ParseDashboardAccessToken("opaque.key.with-dots")
|
|
require.NoError(t, err)
|
|
assert.False(t, internal)
|
|
assert.Empty(t, identity)
|
|
|
|
external := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"iss": "external-issuer",
|
|
"aud": authTokenAudience,
|
|
"exp": time.Now().Add(time.Minute).Unix(),
|
|
})
|
|
externalRaw, err := external.SignedString([]byte("external-secret"))
|
|
require.NoError(t, err)
|
|
_, internal, err = ParseDashboardAccessToken(externalRaw)
|
|
require.NoError(t, err)
|
|
assert.False(t, internal)
|
|
|
|
unknownUse := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"iss": authTokenIssuer,
|
|
"aud": authTokenAudience,
|
|
"token_use": "third_party",
|
|
"exp": time.Now().Add(time.Minute).Unix(),
|
|
})
|
|
unknownUseRaw, err := unknownUse.SignedString([]byte("external-secret"))
|
|
require.NoError(t, err)
|
|
_, internal, err = ParseDashboardAccessToken(unknownUseRaw)
|
|
require.NoError(t, err)
|
|
assert.False(t, internal)
|
|
|
|
proof, _, err := IssueSecurityProof(AuthIdentity{
|
|
UserID: 42, SessionID: "session-1", UserAuthVersion: 1, SessionVersion: 1,
|
|
}, "2fa", []string{"channel.key.read"})
|
|
require.NoError(t, err)
|
|
_, internal, err = ParseDashboardAccessToken(proof)
|
|
assert.True(t, internal)
|
|
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
|
|
|
|
expiredClaims := authClaims{
|
|
TokenUse: accessTokenUse,
|
|
SessionID: "expired-session",
|
|
UserAuthVersion: 1,
|
|
SessionVersion: 1,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Issuer: authTokenIssuer,
|
|
Subject: "42",
|
|
Audience: jwt.ClaimStrings{authTokenAudience},
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
|
NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Minute)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Minute)),
|
|
ID: "expired-token",
|
|
},
|
|
}
|
|
expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, expiredClaims).SignedString(authSigningKey(accessTokenUse))
|
|
require.NoError(t, err)
|
|
_, internal, err = ParseDashboardAccessToken(expired)
|
|
assert.True(t, internal)
|
|
assert.ErrorIs(t, err, ErrAuthTokenExpired)
|
|
}
|
|
|
|
func TestSecurityProofBindsIdentityMethodAndScope(t *testing.T) {
|
|
useTestSessionSecret(t)
|
|
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2}
|
|
proof, _, err := IssueSecurityProof(identity, "2fa", []string{"channel.key.read"})
|
|
require.NoError(t, err)
|
|
|
|
method, err := VerifySecurityProof(proof, identity, "channel.key.read", []string{"2fa", "passkey"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "2fa", method)
|
|
|
|
_, err = VerifySecurityProof(proof, identity, "passkey.delete", []string{"2fa"})
|
|
assert.ErrorIs(t, err, ErrProofScope)
|
|
|
|
_, err = VerifySecurityProof(proof, identity, "channel.key.read", []string{"passkey"})
|
|
assert.ErrorIs(t, err, ErrProofMethod)
|
|
|
|
otherSession := identity
|
|
otherSession.SessionID = "session-2"
|
|
_, err = VerifySecurityProof(proof, otherSession, "channel.key.read", []string{"2fa"})
|
|
assert.True(t, errors.Is(err, ErrAuthTokenInvalid))
|
|
}
|