feat: add API token authentication (closes #87)
- Add api_tokens table migration (007) - Add APIToken model with CRUD operations - Generate tokens with upaas_ prefix + 32 hex chars - Store SHA-256 hash of tokens (not plaintext) - Update APISessionAuth middleware to check Bearer tokens - Add POST/GET/DELETE /api/v1/tokens endpoints - Token creation returns plaintext once; list never exposes it - Expired and revoked tokens are rejected - Tests for creation, listing, deletion, bearer auth, revocation
This commit is contained in:
293
internal/handlers/api_token_test.go
Normal file
293
internal/handlers/api_token_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// tokenRouter builds a chi router with token + app API routes.
|
||||
func tokenRouter(tc *testContext) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Route("/api/v1", func(apiR chi.Router) {
|
||||
apiR.Post("/login", tc.handlers.HandleAPILoginPOST())
|
||||
|
||||
apiR.Group(func(apiR chi.Router) {
|
||||
apiR.Use(tc.middleware.APISessionAuth())
|
||||
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
|
||||
apiR.Post("/tokens", tc.handlers.HandleAPICreateToken())
|
||||
apiR.Get("/tokens", tc.handlers.HandleAPIListTokens())
|
||||
apiR.Delete(
|
||||
"/tokens/{tokenID}",
|
||||
tc.handlers.HandleAPIDeleteToken(),
|
||||
)
|
||||
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAPICreateToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
r := tokenRouter(tc)
|
||||
|
||||
body := `{"name":"my-ci-token"}`
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost, "/api/v1/tokens",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "my-ci-token", resp["name"])
|
||||
assert.Contains(t, resp["token"], "upaas_")
|
||||
assert.NotEmpty(t, resp["id"])
|
||||
}
|
||||
|
||||
func TestAPICreateTokenMissingName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
r := tokenRouter(tc)
|
||||
|
||||
body := `{"name":""}`
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost, "/api/v1/tokens",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIListTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
r := tokenRouter(tc)
|
||||
|
||||
// Create two tokens.
|
||||
for _, name := range []string{"token-a", "token-b"} {
|
||||
body := `{"name":"` + name + `"}`
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost, "/api/v1/tokens",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusCreated, rr.Code)
|
||||
}
|
||||
|
||||
// List tokens.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var tokens []map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
|
||||
assert.Len(t, tokens, 2)
|
||||
|
||||
// Plaintext token must NOT appear in list.
|
||||
for _, tok := range tokens {
|
||||
assert.Nil(t, tok["token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDeleteToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
r := tokenRouter(tc)
|
||||
|
||||
// Create a token.
|
||||
body := `{"name":"delete-me"}`
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost, "/api/v1/tokens",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||
|
||||
tokenID, ok := created["id"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
// Delete it.
|
||||
req = httptest.NewRequest(
|
||||
http.MethodDelete, "/api/v1/tokens/"+tokenID, nil,
|
||||
)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
// List should be empty.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
var tokens []map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
|
||||
assert.Empty(t, tokens)
|
||||
}
|
||||
|
||||
func TestAPIBearerTokenAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
r := tokenRouter(tc)
|
||||
|
||||
// Create a token via session auth.
|
||||
body := `{"name":"bearer-test"}`
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost, "/api/v1/tokens",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||
|
||||
plaintext, ok := created["token"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
// Use Bearer token to access an authenticated endpoint.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+plaintext)
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIBearerTokenInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc := setupTestHandlers(t)
|
||||
r := tokenRouter(tc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
|
||||
req.Header.Set("Authorization", "Bearer upaas_invalidtoken1234567890ab")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIBearerTokenRevoked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
r := tokenRouter(tc)
|
||||
|
||||
// Create then delete a token.
|
||||
body := `{"name":"revoke-test"}`
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost, "/api/v1/tokens",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||
|
||||
plaintext, ok := created["token"].(string)
|
||||
require.True(t, ok)
|
||||
tokenID, ok := created["id"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
// Delete (revoke) the token.
|
||||
req = httptest.NewRequest(
|
||||
http.MethodDelete, "/api/v1/tokens/"+tokenID, nil,
|
||||
)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
// Try to use the revoked token.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+plaintext)
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
Reference in New Issue
Block a user