Files
silo-server/internal/auth/oauth_store.go
T

368 lines
11 KiB
Go

package auth
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// OAuthSession is one in-flight OAuth authorization-code exchange.
//
// State is the primary key — an HMAC-signed string generated by the
// host on /init and verified on /callback. ProviderState is opaque JSON
// the plugin returned from InitAuthorize and that the host round-trips
// back on ExchangeCode (e.g., PKCE verifier, OIDC nonce). LinkingUserID
// is empty when the flow is a fresh login; non-empty when an existing
// user is attaching another identity from /me/account.
type OAuthSession struct {
State string
InstallID string
RedirectURI string
LinkingUserID string
ProviderState []byte
NextURL string
CreatedAt time.Time
ExpiresAt time.Time
}
// OAuthStore is the persistence interface used by the OAuth handlers.
// Defined as an interface so handler tests can supply InMemoryOAuthStore.
type OAuthStore interface {
Insert(ctx context.Context, s OAuthSession) error
GetAndDelete(ctx context.Context, state string) (OAuthSession, error)
DeleteExpired(ctx context.Context, now time.Time) (int, error)
}
// ErrOAuthSessionNotFound is returned by GetAndDelete when no row matches.
var ErrOAuthSessionNotFound = errors.New("oauth_session not found")
type OAuthCompletion struct {
Code string
AccessToken string
RefreshToken string
ExpiresIn int
NextURL string
CreatedAt time.Time
ExpiresAt time.Time
}
type OAuthCompletionStore interface {
InsertCompletion(ctx context.Context, c OAuthCompletion) error
GetAndDeleteCompletion(ctx context.Context, code string) (OAuthCompletion, error)
DeleteExpiredCompletions(ctx context.Context, now time.Time) (int, error)
}
var ErrOAuthCompletionNotFound = errors.New("oauth_completion not found")
// PGOAuthStore is the Postgres-backed OAuthStore.
type PGOAuthStore struct {
pool *pgxpool.Pool
completionKey [32]byte
}
func NewPGOAuthStore(pool *pgxpool.Pool, completionSecret ...[]byte) *PGOAuthStore {
var material []byte
if len(completionSecret) > 0 {
material = completionSecret[0]
}
if len(material) == 0 {
material = []byte("silo-oauth-completion-default")
}
key := sha256.Sum256(append([]byte("silo/oauth-completion/v1:"), material...))
return &PGOAuthStore{pool: pool, completionKey: key}
}
func (s *PGOAuthStore) Insert(ctx context.Context, sess OAuthSession) error {
if err := validateSession(&sess); err != nil {
return err
}
_, err := s.pool.Exec(ctx, `
INSERT INTO oauth_session (state, install_id, redirect_uri, linking_user_id, provider_state, next_url, expires_at)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7)
`, sess.State, sess.InstallID, sess.RedirectURI, sess.LinkingUserID, sess.ProviderState, sess.NextURL, sess.ExpiresAt)
if err != nil {
return fmt.Errorf("insert oauth_session: %w", err)
}
return nil
}
func (s *PGOAuthStore) GetAndDelete(ctx context.Context, state string) (OAuthSession, error) {
row := s.pool.QueryRow(ctx, `
DELETE FROM oauth_session WHERE state = $1
RETURNING state, install_id, redirect_uri, COALESCE(linking_user_id, ''), provider_state, next_url, created_at, expires_at
`, state)
var out OAuthSession
if err := row.Scan(&out.State, &out.InstallID, &out.RedirectURI, &out.LinkingUserID, &out.ProviderState, &out.NextURL, &out.CreatedAt, &out.ExpiresAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return OAuthSession{}, ErrOAuthSessionNotFound
}
return OAuthSession{}, fmt.Errorf("get_and_delete oauth_session: %w", err)
}
return out, nil
}
func (s *PGOAuthStore) DeleteExpired(ctx context.Context, now time.Time) (int, error) {
tag, err := s.pool.Exec(ctx, `DELETE FROM oauth_session WHERE expires_at < $1`, now)
if err != nil {
return 0, fmt.Errorf("delete expired oauth_session: %w", err)
}
return int(tag.RowsAffected()), nil
}
func (s *PGOAuthStore) InsertCompletion(ctx context.Context, c OAuthCompletion) error {
if err := validateCompletion(&c); err != nil {
return err
}
codeHash := oauthCompletionCodeHash(c.Code)
tokenCiphertext, err := s.encryptCompletionTokens(c, codeHash)
if err != nil {
return fmt.Errorf("encrypt oauth_completion tokens: %w", err)
}
_, err = s.pool.Exec(ctx, `
INSERT INTO oauth_completion (code_hash, token_ciphertext, expires_in, next_url, expires_at)
VALUES ($1, $2, $3, $4, $5)
`, codeHash, tokenCiphertext, c.ExpiresIn, c.NextURL, c.ExpiresAt)
if err != nil {
return fmt.Errorf("insert oauth_completion: %w", err)
}
return nil
}
func (s *PGOAuthStore) GetAndDeleteCompletion(ctx context.Context, code string) (OAuthCompletion, error) {
codeHash := oauthCompletionCodeHash(code)
row := s.pool.QueryRow(ctx, `
DELETE FROM oauth_completion WHERE code_hash = $1 AND expires_at >= now()
RETURNING token_ciphertext, expires_in, next_url, created_at, expires_at
`, codeHash)
var out OAuthCompletion
var tokenCiphertext string
if err := row.Scan(&tokenCiphertext, &out.ExpiresIn, &out.NextURL, &out.CreatedAt, &out.ExpiresAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return OAuthCompletion{}, ErrOAuthCompletionNotFound
}
return OAuthCompletion{}, fmt.Errorf("get_and_delete oauth_completion: %w", err)
}
out.Code = code
if err := s.decryptCompletionTokens(tokenCiphertext, codeHash, &out); err != nil {
return OAuthCompletion{}, fmt.Errorf("decrypt oauth_completion tokens: %w", err)
}
return out, nil
}
func (s *PGOAuthStore) DeleteExpiredCompletions(ctx context.Context, now time.Time) (int, error) {
tag, err := s.pool.Exec(ctx, `DELETE FROM oauth_completion WHERE expires_at < $1`, now)
if err != nil {
return 0, fmt.Errorf("delete expired oauth_completion: %w", err)
}
return int(tag.RowsAffected()), nil
}
type oauthCompletionTokenPayload struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
func (s *PGOAuthStore) encryptCompletionTokens(c OAuthCompletion, codeHash string) (string, error) {
block, err := aes.NewCipher(s.completionKey[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
plaintext, err := json.Marshal(oauthCompletionTokenPayload{
AccessToken: c.AccessToken,
RefreshToken: c.RefreshToken,
})
if err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, plaintext, []byte(codeHash))
return base64.RawURLEncoding.EncodeToString(sealed), nil
}
func (s *PGOAuthStore) decryptCompletionTokens(ciphertext, codeHash string, out *OAuthCompletion) error {
sealed, err := base64.RawURLEncoding.DecodeString(ciphertext)
if err != nil {
return err
}
block, err := aes.NewCipher(s.completionKey[:])
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
if len(sealed) < gcm.NonceSize() {
return fmt.Errorf("ciphertext too short")
}
nonce, body := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, body, []byte(codeHash))
if err != nil {
return err
}
var payload oauthCompletionTokenPayload
if err := json.Unmarshal(plaintext, &payload); err != nil {
return err
}
out.AccessToken = payload.AccessToken
out.RefreshToken = payload.RefreshToken
return nil
}
func oauthCompletionCodeHash(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
// InMemoryOAuthStore is a process-local OAuthStore for tests.
type InMemoryOAuthStore struct {
mu sync.Mutex
rows map[string]OAuthSession
completions map[string]OAuthCompletion
}
func NewInMemoryOAuthStore() *InMemoryOAuthStore {
return &InMemoryOAuthStore{
rows: make(map[string]OAuthSession),
completions: make(map[string]OAuthCompletion),
}
}
func (s *InMemoryOAuthStore) Insert(_ context.Context, sess OAuthSession) error {
if err := validateSession(&sess); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.rows[sess.State]; exists {
return fmt.Errorf("insert oauth_session: state %q already exists", sess.State)
}
if sess.CreatedAt.IsZero() {
sess.CreatedAt = time.Now().UTC()
}
s.rows[sess.State] = sess
return nil
}
func (s *InMemoryOAuthStore) GetAndDelete(_ context.Context, state string) (OAuthSession, error) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.rows[state]
if !ok {
return OAuthSession{}, ErrOAuthSessionNotFound
}
delete(s.rows, state)
return sess, nil
}
func (s *InMemoryOAuthStore) DeleteExpired(_ context.Context, now time.Time) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for k, v := range s.rows {
if v.ExpiresAt.Before(now) {
delete(s.rows, k)
n++
}
}
return n, nil
}
func (s *InMemoryOAuthStore) InsertCompletion(_ context.Context, c OAuthCompletion) error {
if err := validateCompletion(&c); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.completions[c.Code]; exists {
return fmt.Errorf("insert oauth_completion: code %q already exists", c.Code)
}
if c.CreatedAt.IsZero() {
c.CreatedAt = time.Now().UTC()
}
s.completions[c.Code] = c
return nil
}
func (s *InMemoryOAuthStore) GetAndDeleteCompletion(_ context.Context, code string) (OAuthCompletion, error) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.completions[code]
if !ok || c.ExpiresAt.Before(time.Now().UTC()) {
delete(s.completions, code)
return OAuthCompletion{}, ErrOAuthCompletionNotFound
}
delete(s.completions, code)
return c, nil
}
func (s *InMemoryOAuthStore) DeleteExpiredCompletions(_ context.Context, now time.Time) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for k, v := range s.completions {
if v.ExpiresAt.Before(now) {
delete(s.completions, k)
n++
}
}
return n, nil
}
func validateSession(sess *OAuthSession) error {
if sess.State == "" || sess.InstallID == "" {
return fmt.Errorf("oauth_session: state and install_id required")
}
if sess.ExpiresAt.IsZero() {
return fmt.Errorf("oauth_session: expires_at required")
}
if sess.RedirectURI == "" {
return fmt.Errorf("oauth_session: redirect_uri required")
}
if sess.NextURL == "" {
sess.NextURL = "/"
}
if len(sess.ProviderState) == 0 {
sess.ProviderState = []byte("{}")
}
return nil
}
func validateCompletion(c *OAuthCompletion) error {
if c.Code == "" {
return fmt.Errorf("oauth_completion: code required")
}
if c.AccessToken == "" || c.RefreshToken == "" {
return fmt.Errorf("oauth_completion: tokens required")
}
if c.ExpiresIn <= 0 {
return fmt.Errorf("oauth_completion: expires_in required")
}
if c.ExpiresAt.IsZero() {
return fmt.Errorf("oauth_completion: expires_at required")
}
if c.NextURL == "" {
c.NextURL = "/"
}
return nil
}