Initial commit
This commit is contained in:
commit
89053153cc
8152 files changed
+2086461
No files matched your search
@@ -0,0 +1,137 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cookie names. The login-tx cookie is scoped to /api/auth so it is only
|
||||
// sent to the endpoints that need it.
|
||||
const (
|
||||
CookieSession = "fs_session"
|
||||
CookieLoginTx = "fs_auth_tx"
|
||||
)
|
||||
|
||||
// Session is the signed-in identity kept in the session cookie. It is a
|
||||
// self-contained, HMAC-protected claim set — not a bearer token: it carries
|
||||
// no issuer credentials and only opens this API.
|
||||
type Session struct {
|
||||
Sub string
|
||||
Name string
|
||||
Email string
|
||||
Roles []string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Valid reports whether the session is still within its TTL.
|
||||
func (s Session) Valid() bool { return time.Now().Before(s.ExpiresAt) }
|
||||
|
||||
// ErrTampered is returned when a cookie value fails its HMAC check or is
|
||||
// malformed — treat it exactly like a missing cookie.
|
||||
var ErrTampered = errors.New("auth cookie failed integrity check")
|
||||
|
||||
// Signer seals values into "<base64url(json)>.<base64url(hmac-sha256)>"
|
||||
// cookie payloads and opens them back. Construction is cheap; keep one
|
||||
// per server and share it.
|
||||
type Signer struct{ key []byte }
|
||||
|
||||
// NewSigner returns a Signer keyed by secret.
|
||||
func NewSigner(secret string) *Signer { return &Signer{key: []byte(secret)} }
|
||||
|
||||
// Seal encodes v into a signed cookie value.
|
||||
func (s *Signer) Seal(v any) (string, error) {
|
||||
payload, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal cookie payload: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(payload) + "." + s.mac(payload), nil
|
||||
}
|
||||
|
||||
// Open verifies a sealed value and decodes it into v.
|
||||
func (s *Signer) Open(sealed string, v any) error {
|
||||
value, mac, ok := strings.Cut(sealed, ".")
|
||||
if !ok {
|
||||
return ErrTampered
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return ErrTampered
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(mac)
|
||||
if err != nil {
|
||||
return ErrTampered
|
||||
}
|
||||
if !hmac.Equal(sig, s.macBytes(payload)) {
|
||||
return ErrTampered
|
||||
}
|
||||
if err := json.Unmarshal(payload, v); err != nil {
|
||||
return ErrTampered
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Signer) mac(payload []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(s.macBytes(payload))
|
||||
}
|
||||
|
||||
func (s *Signer) macBytes(payload []byte) []byte {
|
||||
h := hmac.New(sha256.New, s.key)
|
||||
h.Write(payload)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// NewSessionCookie returns the Set-Cookie for a session valid for ttl.
|
||||
func (s *Signer) NewSessionCookie(sess Session, ttl time.Duration, secure bool) (*http.Cookie, error) {
|
||||
sess.ExpiresAt = time.Now().Add(ttl)
|
||||
value, err := s.Seal(sess)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Cookie{
|
||||
Name: CookieSession,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewLoginTxCookie returns the short-lived Set-Cookie carrying tx between
|
||||
// the login redirect and the callback.
|
||||
func (s *Signer) NewLoginTxCookie(tx LoginTx, secure bool) (*http.Cookie, error) {
|
||||
value, err := s.Seal(tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Cookie{
|
||||
Name: CookieLoginTx,
|
||||
Value: value,
|
||||
Path: "/api/auth",
|
||||
MaxAge: int(txTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Expired returns a Set-Cookie that deletes the named cookie.
|
||||
func Expired(name string, secure bool) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignerSealOpen(t *testing.T) {
|
||||
s := NewSigner("test-secret")
|
||||
tx, err := NewLoginTx("/some/path")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoginTx: %v", err)
|
||||
}
|
||||
|
||||
sealed, err := s.Seal(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
var opened LoginTx
|
||||
if err := s.Open(sealed, &opened); err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
// Compare fields: time.Time equality also compares the monotonic clock,
|
||||
// which the JSON roundtrip drops.
|
||||
if opened.State != tx.State || opened.Nonce != tx.Nonce ||
|
||||
opened.Verifier != tx.Verifier || opened.ReturnTo != tx.ReturnTo ||
|
||||
!opened.ExpiresAt.Equal(tx.ExpiresAt) {
|
||||
t.Errorf("opened = %+v, want %+v", opened, tx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignerRejectsTampering(t *testing.T) {
|
||||
s := NewSigner("test-secret")
|
||||
sess := Session{Sub: "user-1", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
sealed, err := s.Seal(sess)
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value func(string) string
|
||||
reason string
|
||||
}{
|
||||
{"flipped payload", func(v string) string {
|
||||
return flipLastChar(strings.Split(v, ".")[0]) + "." + strings.Split(v, ".")[1]
|
||||
}, "forged payload"},
|
||||
{"flipped mac", func(v string) string {
|
||||
return strings.Split(v, ".")[0] + "." + flipLastChar(strings.Split(v, ".")[1])
|
||||
}, "forged mac"},
|
||||
{"wrong key", func(v string) string {
|
||||
other, _ := NewSigner("other-secret").Seal(sess)
|
||||
return other
|
||||
}, "signed with a different key"},
|
||||
{"garbage", func(string) string { return "not-a-sealed-value" }, "garbage"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var out Session
|
||||
if err := s.Open(tt.value(sealed), &out); err == nil {
|
||||
t.Errorf("Open accepted %s", tt.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAndTxCookies(t *testing.T) {
|
||||
s := NewSigner("test-secret")
|
||||
|
||||
sessCookie, err := s.NewSessionCookie(Session{Sub: "u1"}, 2*time.Hour, true)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionCookie: %v", err)
|
||||
}
|
||||
if sessCookie.Name != CookieSession || sessCookie.Path != "/" {
|
||||
t.Errorf("session cookie = %+v", sessCookie)
|
||||
}
|
||||
if !sessCookie.HttpOnly || !sessCookie.Secure || sessCookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Errorf("session cookie attrs = %+v", sessCookie)
|
||||
}
|
||||
var sess Session
|
||||
if err := s.Open(sessCookie.Value, &sess); err != nil {
|
||||
t.Fatalf("Open session: %v", err)
|
||||
}
|
||||
if !sess.Valid() {
|
||||
t.Error("session with future ExpiresAt should be valid")
|
||||
}
|
||||
sess.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
if sess.Valid() {
|
||||
t.Error("expired session should be invalid")
|
||||
}
|
||||
|
||||
tx, err := NewLoginTx("/")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoginTx: %v", err)
|
||||
}
|
||||
if !tx.Valid() {
|
||||
t.Error("fresh login tx should be valid")
|
||||
}
|
||||
tx.ExpiresAt = time.Now().Add(-time.Second)
|
||||
if tx.Valid() {
|
||||
t.Error("expired login tx should be invalid")
|
||||
}
|
||||
|
||||
tx, _ = NewLoginTx("/")
|
||||
txCookie, err := s.NewLoginTxCookie(tx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoginTxCookie: %v", err)
|
||||
}
|
||||
if txCookie.Name != CookieLoginTx || txCookie.Path != "/api/auth" {
|
||||
t.Errorf("tx cookie = %+v", txCookie)
|
||||
}
|
||||
if !txCookie.HttpOnly || txCookie.Secure {
|
||||
t.Errorf("tx cookie attrs = %+v", txCookie)
|
||||
}
|
||||
|
||||
expired := Expired(CookieSession, true)
|
||||
if expired.Name != CookieSession || expired.MaxAge != -1 {
|
||||
t.Errorf("Expired cookie = %+v", expired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoginTxUsesDistinctSecrets(t *testing.T) {
|
||||
a, _ := NewLoginTx("/")
|
||||
b, _ := NewLoginTx("/")
|
||||
if a.State == b.State || a.Nonce == b.Nonce || a.Verifier == b.Verifier {
|
||||
t.Error("state/nonce/verifier must be fresh per login")
|
||||
}
|
||||
}
|
||||
|
||||
// flipLastChar swaps the last character of a base64url value so any
|
||||
// signature or payload check must fail.
|
||||
func flipLastChar(v string) string {
|
||||
last := "A"
|
||||
if strings.HasSuffix(v, last) {
|
||||
last = "B"
|
||||
}
|
||||
return v[:len(v)-1] + last
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Package auth implements the browser login flow against an OpenID Connect
|
||||
// issuer: authorization code + PKCE (S256), discovery-driven endpoints, and
|
||||
// RS256 ID token verification via JWKS. The signed-in identity is kept in an
|
||||
// HMAC-protected session cookie (cookie.go) — tokens never reach the browser.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// txTTL bounds the login transaction: the gap between redirecting to the
|
||||
// issuer and the callback that completes it.
|
||||
const txTTL = 10 * time.Minute
|
||||
|
||||
// Config identifies this app to the issuer. Scopes defaults to
|
||||
// ["openid","profile","email"] when empty.
|
||||
type Config struct {
|
||||
Issuer string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
// Client is the configured OIDC relying party. Discovery happens once in
|
||||
// New; the client is immutable and safe for concurrent use.
|
||||
type Client struct {
|
||||
oauth oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// New discovers the issuer's endpoints and returns a ready client.
|
||||
func New(ctx context.Context, cfg Config) (*Client, error) {
|
||||
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover issuer %s: %w", cfg.Issuer, err)
|
||||
}
|
||||
if len(cfg.Scopes) == 0 {
|
||||
cfg.Scopes = []string{"openid", "profile", "email"}
|
||||
}
|
||||
return &Client{
|
||||
oauth: oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: cfg.Scopes,
|
||||
},
|
||||
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// User is the verified identity established by a completed login.
|
||||
type User struct {
|
||||
Sub string
|
||||
Name string
|
||||
Email string
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// LoginTx is the per-login state kept in a short-lived cookie between the
|
||||
// redirect to the issuer and the callback: state and nonce guard against
|
||||
// CSRF and replay, the verifier carries PKCE, ReturnTo is where the browser
|
||||
// lands afterwards.
|
||||
type LoginTx struct {
|
||||
State string
|
||||
Nonce string
|
||||
Verifier string
|
||||
ReturnTo string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// NewLoginTx creates a login transaction with fresh state, nonce, and PKCE
|
||||
// verifier for the given post-login path.
|
||||
func NewLoginTx(returnTo string) (LoginTx, error) {
|
||||
state, err := randomToken(16)
|
||||
if err != nil {
|
||||
return LoginTx{}, err
|
||||
}
|
||||
nonce, err := randomToken(16)
|
||||
if err != nil {
|
||||
return LoginTx{}, err
|
||||
}
|
||||
verifier, err := randomToken(48)
|
||||
if err != nil {
|
||||
return LoginTx{}, err
|
||||
}
|
||||
return LoginTx{
|
||||
State: state,
|
||||
Nonce: nonce,
|
||||
Verifier: verifier,
|
||||
ReturnTo: returnTo,
|
||||
ExpiresAt: time.Now().Add(txTTL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Valid reports whether the transaction is still within its TTL.
|
||||
func (tx LoginTx) Valid() bool { return time.Now().Before(tx.ExpiresAt) }
|
||||
|
||||
// AuthCodeURL builds the issuer's authorization URL for tx — PKCE challenge
|
||||
// and nonce included. redirectURI is the callback URL on the origin the
|
||||
// browser came from (it must be registered with the issuer).
|
||||
func (c *Client) AuthCodeURL(tx LoginTx, redirectURI string) string {
|
||||
return c.oauth.AuthCodeURL(tx.State,
|
||||
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
|
||||
oauth2.SetAuthURLParam("nonce", tx.Nonce),
|
||||
oauth2.S256ChallengeOption(tx.Verifier),
|
||||
)
|
||||
}
|
||||
|
||||
// CompleteCode exchanges the authorization code (PKCE verifier from tx) and
|
||||
// verifies the returned ID token — signature via JWKS, plus iss/aud/exp and
|
||||
// the nonce — returning the signed-in user.
|
||||
func (c *Client) CompleteCode(ctx context.Context, code, redirectURI string, tx LoginTx) (User, error) {
|
||||
tok, err := c.oauth.Exchange(ctx, code,
|
||||
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
|
||||
oauth2.VerifierOption(tx.Verifier),
|
||||
)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("exchange authorization code: %w", err)
|
||||
}
|
||||
rawIDToken, ok := tok.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
return User{}, errors.New("token response carries no id_token")
|
||||
}
|
||||
idToken, err := c.verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("verify id_token: %w", err)
|
||||
}
|
||||
if idToken.Nonce != tx.Nonce {
|
||||
return User{}, errors.New("id_token nonce does not match the login transaction")
|
||||
}
|
||||
var claims struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return User{}, fmt.Errorf("decode id_token claims: %w", err)
|
||||
}
|
||||
return User{Sub: idToken.Subject, Name: claims.Name, Email: claims.Email, Roles: claims.Roles}, nil
|
||||
}
|
||||
|
||||
// randomToken returns a URL-safe random string with n bytes of entropy.
|
||||
func randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("read randomness: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
Reference in new issue
Block a user