initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
200
api/internal/auth/auth.go
Normal file
200
api/internal/auth/auth.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// Package auth validates the bearer tokens issued by the go-loose SSO service
|
||||
// and identifies the caller.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// Errors returned by verifiers. They are deliberately coarse: a caller learns
|
||||
// that a token is unacceptable, not why, so probing cannot distinguish an
|
||||
// expired token from a forged one.
|
||||
var (
|
||||
// ErrMissingToken means no credential was presented.
|
||||
ErrMissingToken = errors.New("auth: no token presented")
|
||||
// ErrInvalidToken means the token failed verification for any reason.
|
||||
ErrInvalidToken = errors.New("auth: invalid token")
|
||||
)
|
||||
|
||||
// Claims are the fields the application reads from a go-loose token.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
// Username and Email are copied from the identity provider. They are
|
||||
// optional because a token may only assert the subject.
|
||||
Username string `json:"username,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// User is the identity resolved from a token.
|
||||
type User struct {
|
||||
domain.User
|
||||
}
|
||||
|
||||
// Verifier turns a bearer token into an identity.
|
||||
type Verifier interface {
|
||||
// Verify returns the identity the token asserts. It returns
|
||||
// ErrMissingToken or ErrInvalidToken for unusable credentials.
|
||||
Verify(ctx context.Context, token string) (User, error)
|
||||
// Name identifies the verification strategy, for logging.
|
||||
Name() string
|
||||
}
|
||||
|
||||
// JWTVerifier verifies HS256-signed tokens issued by go-loose.
|
||||
//
|
||||
// Only HS256 is accepted. The algorithm is pinned rather than read from the
|
||||
// token header so that a token cannot select "none" or an asymmetric algorithm
|
||||
// and slip past verification.
|
||||
type JWTVerifier struct {
|
||||
secret []byte
|
||||
audience string
|
||||
issuer string
|
||||
}
|
||||
|
||||
var _ Verifier = (*JWTVerifier)(nil)
|
||||
|
||||
// NewJWTVerifier returns a verifier for tokens signed with secret. audience may
|
||||
// be empty to skip the audience check; issuer likewise.
|
||||
func NewJWTVerifier(secret, audience, issuer string) *JWTVerifier {
|
||||
return &JWTVerifier{secret: []byte(secret), audience: audience, issuer: issuer}
|
||||
}
|
||||
|
||||
// Name implements Verifier.
|
||||
func (v *JWTVerifier) Name() string { return "jwt" }
|
||||
|
||||
// Verify parses and validates the token, then extracts the identity.
|
||||
func (v *JWTVerifier) Verify(ctx context.Context, token string) (User, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if token == "" {
|
||||
return User{}, ErrMissingToken
|
||||
}
|
||||
if len(v.secret) == 0 {
|
||||
return User{}, fmt.Errorf("%w: no signing secret configured", ErrInvalidToken)
|
||||
}
|
||||
|
||||
opts := []jwt.ParserOption{
|
||||
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
|
||||
jwt.WithExpirationRequired(),
|
||||
}
|
||||
if v.audience != "" {
|
||||
opts = append(opts, jwt.WithAudience(v.audience))
|
||||
}
|
||||
if v.issuer != "" {
|
||||
opts = append(opts, jwt.WithIssuer(v.issuer))
|
||||
}
|
||||
|
||||
var claims Claims
|
||||
parsed, err := jwt.ParseWithClaims(token, &claims, keyFunc(v.secret), opts...)
|
||||
if err != nil {
|
||||
// The underlying reason stays unexported so the caller cannot use the
|
||||
// error text to learn why a token was rejected.
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
if !parsed.Valid {
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
if claims.Subject == "" {
|
||||
// Without a subject there is nobody to attribute the request to.
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
|
||||
return User{domain.User{
|
||||
ID: claims.Subject,
|
||||
Username: firstNonEmpty(claims.Username, claims.Subject),
|
||||
Email: claims.Email,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// keyFunc supplies the verification key. The token's own "alg" header is
|
||||
// deliberately ignored because the parser is already pinned to HS256.
|
||||
func keyFunc(secret []byte) jwt.Keyfunc {
|
||||
return func(t *jwt.Token) (any, error) {
|
||||
return secret, nil
|
||||
}
|
||||
}
|
||||
|
||||
// DevVerifier accepts a small set of fixed tokens for local development. It is
|
||||
// only wired up when AUTH_MODE=dev, and refuses to start in production.
|
||||
type DevVerifier struct {
|
||||
tokens map[string]domain.User
|
||||
}
|
||||
|
||||
var _ Verifier = (*DevVerifier)(nil)
|
||||
|
||||
// DefaultDevUser is the identity assumed by the "valid_token" dev token.
|
||||
var DefaultDevUser = domain.User{
|
||||
ID: "dev-user",
|
||||
Username: "dev",
|
||||
Email: "dev@localhost",
|
||||
}
|
||||
|
||||
// NewDevVerifier returns a verifier accepting the given token-to-user mapping.
|
||||
func NewDevVerifier(tokens map[string]domain.User) *DevVerifier {
|
||||
if tokens == nil {
|
||||
tokens = map[string]domain.User{"valid_token": DefaultDevUser}
|
||||
}
|
||||
return &DevVerifier{tokens: tokens}
|
||||
}
|
||||
|
||||
// Name implements Verifier.
|
||||
func (v *DevVerifier) Name() string { return "dev" }
|
||||
|
||||
// Verify looks the token up in the fixed table.
|
||||
func (v *DevVerifier) Verify(ctx context.Context, token string) (User, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if token == "" {
|
||||
return User{}, ErrMissingToken
|
||||
}
|
||||
u, ok := v.tokens[token]
|
||||
if !ok {
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
return User{u}, nil
|
||||
}
|
||||
|
||||
// BearerToken extracts the credential from an Authorization header value.
|
||||
// Both "Bearer <token>" and a bare token are accepted, since some clients send
|
||||
// the latter.
|
||||
func BearerToken(header string) (string, error) {
|
||||
if strings.TrimSpace(header) == "" {
|
||||
return "", ErrMissingToken
|
||||
}
|
||||
|
||||
// The scheme is split before trimming the credential, so "Bearer " with
|
||||
// nothing after it is reported as a missing token rather than read as a
|
||||
// bare token whose value is the word "Bearer".
|
||||
scheme, rest, hasSpace := strings.Cut(header, " ")
|
||||
if strings.EqualFold(scheme, "bearer") {
|
||||
token := strings.TrimSpace(rest)
|
||||
if !hasSpace || token == "" {
|
||||
return "", ErrMissingToken
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
header = strings.TrimSpace(header)
|
||||
// A bare token is accepted, but reject anything shaped like another scheme.
|
||||
if i := strings.IndexByte(header, ' '); i > 0 {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
return header, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
360
api/internal/auth/auth_test.go
Normal file
360
api/internal/auth/auth_test.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const testSecret = "test-signing-secret"
|
||||
|
||||
// sign builds a token with the given claims and method.
|
||||
func sign(t *testing.T, method jwt.SigningMethod, claims jwt.Claims) string {
|
||||
t.Helper()
|
||||
|
||||
s, err := jwt.NewWithClaims(method, claims).SignedString([]byte(testSecret))
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// validClaims returns an unexpired claim set for the verifier under test.
|
||||
func validClaims() Claims {
|
||||
return Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "user-42",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||
},
|
||||
Username: "ada",
|
||||
Email: "ada@example.com",
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTVerifierAcceptsValidToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := NewJWTVerifier(testSecret, "", "")
|
||||
got, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, validClaims()))
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if got.ID != "user-42" {
|
||||
t.Errorf("ID = %q, want user-42", got.ID)
|
||||
}
|
||||
if got.Username != "ada" {
|
||||
t.Errorf("Username = %q, want ada", got.Username)
|
||||
}
|
||||
if got.Email != "ada@example.com" {
|
||||
t.Errorf("Email = %q", got.Email)
|
||||
}
|
||||
if v.Name() != "jwt" {
|
||||
t.Errorf("Name = %q, want jwt", v.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTVerifierFallsBackToSubjectForUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A token that only asserts a subject still has to yield a usable identity,
|
||||
// so the username defaults to the subject.
|
||||
claims := Claims{RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "user-7",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
}}
|
||||
v := NewJWTVerifier(testSecret, "", "")
|
||||
got, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, claims))
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if got.Username != "user-7" {
|
||||
t.Errorf("Username = %q, want the subject as fallback", got.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTVerifierRejections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expired := Claims{RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "user-42",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
|
||||
}}
|
||||
noExp := Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "user-42"}}
|
||||
noSub := Claims{RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"expired", sign(t, jwt.SigningMethodHS256, expired)},
|
||||
{"missing expiration", sign(t, jwt.SigningMethodHS256, noExp)},
|
||||
{"missing subject", sign(t, jwt.SigningMethodHS256, noSub)},
|
||||
{"wrong secret", signWithSecret(t, jwt.SigningMethodHS256, validClaims(), "other-secret")},
|
||||
// The algorithm is pinned, so a token cannot pick "none" and skip
|
||||
// signature verification entirely.
|
||||
{"alg none", unsignedToken(t, validClaims())},
|
||||
{"garbage", "not-a-token"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := NewJWTVerifier(testSecret, "", "")
|
||||
if _, err := v.Verify(context.Background(), tc.token); err == nil {
|
||||
t.Error("Verify accepted a token it should have rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTVerifierReportsMissingTokenDistinctly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The middleware needs to tell "you sent nothing" from "what you sent is
|
||||
// bad" so it can word the challenge properly.
|
||||
v := NewJWTVerifier(testSecret, "", "")
|
||||
if _, err := v.Verify(context.Background(), ""); !errors.Is(err, ErrMissingToken) {
|
||||
t.Errorf("err = %v, want ErrMissingToken", err)
|
||||
}
|
||||
if _, err := v.Verify(context.Background(), "garbage"); !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("err = %v, want ErrInvalidToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTVerifierRequiresConfiguredSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// An empty secret must reject everything rather than accept a token signed
|
||||
// with an empty key.
|
||||
v := NewJWTVerifier("", "", "")
|
||||
_, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, validClaims()))
|
||||
if !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("err = %v, want ErrInvalidToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTVerifierEnforcesAudienceAndIssuer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
claims := validClaims()
|
||||
claims.Audience = jwt.ClaimStrings{"go-places"}
|
||||
claims.Issuer = "go-loose"
|
||||
token := sign(t, jwt.SigningMethodHS256, claims)
|
||||
|
||||
matching := NewJWTVerifier(testSecret, "go-places", "go-loose")
|
||||
if _, err := matching.Verify(context.Background(), token); err != nil {
|
||||
t.Errorf("Verify with matching aud/iss: %v", err)
|
||||
}
|
||||
|
||||
// A token minted for a different client must not be accepted, or a token
|
||||
// issued for another service in the same cluster would grant access.
|
||||
wrongAudience := NewJWTVerifier(testSecret, "some-other-app", "go-loose")
|
||||
if _, err := wrongAudience.Verify(context.Background(), token); err == nil {
|
||||
t.Error("Verify accepted a token with the wrong audience")
|
||||
}
|
||||
wrongIssuer := NewJWTVerifier(testSecret, "go-places", "some-other-issuer")
|
||||
if _, err := wrongIssuer.Verify(context.Background(), token); err == nil {
|
||||
t.Error("Verify accepted a token from the wrong issuer")
|
||||
}
|
||||
|
||||
// Skipping the checks is the documented behaviour of the empty arguments.
|
||||
unpinned := NewJWTVerifier(testSecret, "", "")
|
||||
if _, err := unpinned.Verify(context.Background(), token); err != nil {
|
||||
t.Errorf("Verify with no aud/iss configured: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTVerifierHonoursContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
v := NewJWTVerifier(testSecret, "", "")
|
||||
_, err := v.Verify(ctx, sign(t, jwt.SigningMethodHS256, validClaims()))
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevVerifierAcceptsOnlyConfiguredTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := NewDevVerifier(nil)
|
||||
got, err := v.Verify(context.Background(), "valid_token")
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if got.ID != DefaultDevUser.ID {
|
||||
t.Errorf("ID = %q, want %q", got.ID, DefaultDevUser.ID)
|
||||
}
|
||||
if v.Name() != "dev" {
|
||||
t.Errorf("Name = %q, want dev", v.Name())
|
||||
}
|
||||
|
||||
if _, err := v.Verify(context.Background(), "anything-else"); !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("err = %v, want ErrInvalidToken", err)
|
||||
}
|
||||
if _, err := v.Verify(context.Background(), ""); !errors.Is(err, ErrMissingToken) {
|
||||
t.Errorf("err = %v, want ErrMissingToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "standard", header: "Bearer abc.def.ghi", want: "abc.def.ghi"},
|
||||
{name: "lowercase scheme", header: "bearer abc", want: "abc"},
|
||||
{name: "mixed case scheme", header: "BeArEr abc", want: "abc"},
|
||||
{name: "extra spaces", header: "Bearer abc ", want: "abc"},
|
||||
{name: "bare token", header: "abc.def.ghi", want: "abc.def.ghi"},
|
||||
{name: "empty", header: "", wantErr: ErrMissingToken},
|
||||
{name: "whitespace only", header: " ", wantErr: ErrMissingToken},
|
||||
{name: "scheme without token", header: "Bearer ", wantErr: ErrMissingToken},
|
||||
// A header naming some other scheme must not be silently read as a
|
||||
// bearer token.
|
||||
{name: "other scheme", header: "Basic dXNlcjpwYXNz", wantErr: ErrInvalidToken},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := BearerToken(tc.header)
|
||||
if tc.wantErr != nil {
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("token = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareAttachesUserToContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := NewMiddleware(NewDevVerifier(nil))
|
||||
|
||||
var seen User
|
||||
var ok bool
|
||||
h := m.WrapFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen, ok = FromContext(r.Context())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/places", nil)
|
||||
req.Header.Set("Authorization", "Bearer valid_token")
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("the handler could not read the identity from the context")
|
||||
}
|
||||
if seen.ID != DefaultDevUser.ID {
|
||||
t.Errorf("user = %q, want %q", seen.ID, DefaultDevUser.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareRejectsUnusableCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := NewMiddleware(NewDevVerifier(nil))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
body string
|
||||
}{
|
||||
{name: "no header", body: "a bearer token is required"},
|
||||
{name: "bad token", header: "Bearer nope", body: "the token was rejected"},
|
||||
{name: "other scheme", header: "Basic dXNlcjpwYXNz", body: "the token was rejected"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Local to the subtest: the subtests run in parallel, so a shared
|
||||
// flag would be a data race.
|
||||
called := false
|
||||
h := m.WrapFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/places", nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Set("Authorization", tc.header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if called {
|
||||
t.Error("the protected handler ran without a valid credential")
|
||||
}
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("WWW-Authenticate"); !strings.HasPrefix(got, "Bearer") {
|
||||
t.Errorf("WWW-Authenticate = %q, want a Bearer challenge", got)
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, tc.body) {
|
||||
t.Errorf("body = %q, want it to mention %q", body, tc.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromContextReportsAbsentIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, ok := FromContext(context.Background()); ok {
|
||||
t.Error("FromContext found an identity on a bare context")
|
||||
}
|
||||
}
|
||||
|
||||
// signWithSecret signs with a different key to model a forged token.
|
||||
func signWithSecret(t *testing.T, method jwt.SigningMethod, claims jwt.Claims, secret string) string {
|
||||
t.Helper()
|
||||
|
||||
s, err := jwt.NewWithClaims(method, claims).SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// unsignedToken builds an alg=none token, the classic JWT downgrade attack.
|
||||
func unsignedToken(t *testing.T, claims jwt.Claims) string {
|
||||
t.Helper()
|
||||
|
||||
s, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).
|
||||
SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
63
api/internal/auth/middleware.go
Normal file
63
api/internal/auth/middleware.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// WithUser stores the verified identity on the request context.
|
||||
func WithUser(ctx context.Context, u User) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, u)
|
||||
}
|
||||
|
||||
// FromContext returns the identity attached by the middleware.
|
||||
func FromContext(ctx context.Context) (User, bool) {
|
||||
u, ok := ctx.Value(ctxKey{}).(User)
|
||||
return u, ok
|
||||
}
|
||||
|
||||
// Middleware verifies the Authorization header and rejects requests that carry
|
||||
// no usable credential.
|
||||
type Middleware struct {
|
||||
verifier Verifier
|
||||
}
|
||||
|
||||
// NewMiddleware returns middleware backed by the verifier.
|
||||
func NewMiddleware(v Verifier) *Middleware {
|
||||
return &Middleware{verifier: v}
|
||||
}
|
||||
|
||||
// Wrap protects h, making the verified identity available to it.
|
||||
func (m *Middleware) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := BearerToken(r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
unauthorized(w, err)
|
||||
return
|
||||
}
|
||||
user, err := m.verifier.Verify(r.Context(), token)
|
||||
if err != nil {
|
||||
unauthorized(w, err)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), user)))
|
||||
})
|
||||
}
|
||||
|
||||
// WrapFunc protects a handler function.
|
||||
func (m *Middleware) WrapFunc(next http.HandlerFunc) http.HandlerFunc {
|
||||
return m.Wrap(next).ServeHTTP
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter, err error) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="go-places"`)
|
||||
// The response deliberately does not echo the reason.
|
||||
if errors.Is(err, ErrMissingToken) {
|
||||
http.Error(w, "unauthorized: a bearer token is required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Error(w, "unauthorized: the token was rejected", http.StatusUnauthorized)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue