Skip to content

Commit 19e503f

Browse files
committed
address OAuth PR review: conditional init, lint fixes, refresh concurrency
1 parent 87b09a3 commit 19e503f

11 files changed

Lines changed: 114 additions & 43 deletions

File tree

.env.example

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,14 +279,15 @@ OAUTH__CLIENT_ID=
279279
# Default: (empty - OAuth connection disabled)
280280
OAUTH__CLIENT_SECRET=
281281

282-
# OAuth Token Encryption Key (required when OAuth connection is enabled)
282+
# OAuth Token Encryption Key (required only when OAuth connection is enabled)
283283
# Purpose: AES key used to encrypt the Bitbucket access and refresh tokens at
284284
# rest in the oauth_tokens table (AES-GCM, 12-byte random nonce per value).
285285
# Format: 32 bytes (AES-256) encoded as hex or standard base64. Examples:
286-
# base64: OAUTH__TOKEN_ENCRYPTION_KEY=<64-char base64 string>
286+
# base64: OAUTH__TOKEN_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
287287
# hex: OAUTH__TOKEN_ENCRYPTION_KEY=<64-char hex string>
288-
# Default: (empty - the service fails to start if OAuth connection is used)
288+
# Default: (empty - OAuth disabled)
289289
# SECURITY: Must be kept secret and unique per environment. Store in a secrets
290290
# manager, never commit it. Rotating the key renders all existing stored
291-
# tokens unreadable (treat as a disconnect).
291+
# tokens unreadable (treat as a disconnect). When empty, the OAuth
292+
# connection stays disabled and startup does not fail.
292293
OAUTH__TOKEN_ENCRYPTION_KEY=

internal/config/config.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ type webhooksConfig struct {
6464
}
6565

6666
type oauthConfig struct {
67-
ClientID string `koanf:"client_id"`
68-
ClientSecret string `koanf:"client_secret"`
69-
TokenEncryptionKey string `koanf:"token_encryption_key"`
67+
ClientID string `koanf:"client_id"`
68+
ClientSecret string `koanf:"client_secret"`
69+
TokenEncryptionKey string `koanf:"token_encryption_key"`
7070
}
7171

7272
type Config struct {
@@ -142,8 +142,9 @@ func Default() Config {
142142
},
143143
},
144144
OAuth: oauthConfig{
145-
ClientID: "",
146-
ClientSecret: "",
145+
ClientID: "",
146+
ClientSecret: "",
147+
TokenEncryptionKey: "",
147148
},
148149
}
149150
}

internal/config/module.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@ func Module() fx.Option {
8484
},
8585
func(cfg Config) oauth.Config {
8686
return oauth.Config{
87-
ClientID: cfg.OAuth.ClientID,
88-
ClientSecret: cfg.OAuth.ClientSecret,
89-
TokenEncryptionKey: cfg.OAuth.TokenEncryptionKey,
87+
ClientID: cfg.OAuth.ClientID,
88+
ClientSecret: cfg.OAuth.ClientSecret,
89+
TokenEncryptionKey: cfg.OAuth.TokenEncryptionKey,
9090
}
9191
},
9292
),

internal/db/migrations/20260825050007_oauth_tokens.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CREATE TABLE `oauth_tokens` (
55
`user_id` BIGINT UNSIGNED NOT NULL,
66
`access_token` TEXT NOT NULL,
77
`refresh_token` TEXT NOT NULL,
8+
`token_fingerprint` VARCHAR(64) NOT NULL,
89
`scopes` VARCHAR(255) NOT NULL,
910
`expires_at` DATETIME NOT NULL,
1011
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

internal/oauth/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,10 @@ type Config struct {
1111
// encrypt the stored OAuth access and refresh tokens at rest. Required.
1212
TokenEncryptionKey string
1313
}
14+
15+
// Enabled reports whether OAuth is fully configured. All of the Bitbucket
16+
// client credentials and the token encryption key must be present; otherwise
17+
// the OAuth connection stays disabled and startup must not fail.
18+
func (c Config) Enabled() bool {
19+
return c.ClientID != "" && c.ClientSecret != "" && c.TokenEncryptionKey != ""
20+
}

internal/oauth/crypto.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ func NewEncryptorFromConfig(encoded string) (*Encryptor, error) {
5252
return nil, fmt.Errorf("%w: encryption key is required", ErrInvalidKey)
5353
}
5454
for _, dec := range []func(string) ([]byte, error){
55-
func(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) },
5655
hex.DecodeString,
56+
func(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) },
5757
} {
5858
if raw, derr := dec(encoded); derr == nil {
5959
if enc, nerr := NewEncryptor(raw); nerr == nil {
@@ -86,7 +86,7 @@ func (e *Encryptor) Decrypt(ciphertext string) (string, error) {
8686
}
8787
raw, err := base64.StdEncoding.DecodeString(ciphertext)
8888
if err != nil {
89-
return "", fmt.Errorf("%w: %v", ErrInvalidCiphertext, err)
89+
return "", fmt.Errorf("%w: %w", ErrInvalidCiphertext, err)
9090
}
9191
ns := e.gcm.NonceSize()
9292
if len(raw) < ns {
@@ -95,7 +95,7 @@ func (e *Encryptor) Decrypt(ciphertext string) (string, error) {
9595
nonce, ct := raw[:ns], raw[ns:]
9696
plain, err := e.gcm.Open(nil, nonce, ct, nil)
9797
if err != nil {
98-
return "", fmt.Errorf("%w: %v", ErrInvalidCiphertext, err)
98+
return "", fmt.Errorf("%w: %w", ErrInvalidCiphertext, err)
9999
}
100100
return string(plain), nil
101101
}

internal/oauth/crypto_test.go

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
package oauth
1+
package oauth_test
22

33
import (
4+
"encoding/hex"
45
"strings"
56
"testing"
7+
8+
"github.com/bit-issues/backend/internal/oauth"
69
)
710

8-
func testEncryptor(t *testing.T) *Encryptor {
11+
func testEncryptor(t *testing.T) *oauth.Encryptor {
912
t.Helper()
10-
enc, err := NewEncryptorFromConfig("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // 32-byte base64
13+
enc, err := oauth.NewEncryptorFromConfig("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // 32-byte base64
1114
if err != nil {
1215
t.Fatalf("failed to build encryptor: %v", err)
1316
}
@@ -64,23 +67,55 @@ func TestEncryptor_TamperFails(t *testing.T) {
6467
} else {
6568
tampered = "A" + tampered[1:]
6669
}
67-
if _, err := enc.Decrypt(tampered); err == nil {
70+
if _, derr := enc.Decrypt(tampered); derr == nil {
6871
t.Fatalf("expected decryption failure on tampered ciphertext")
6972
}
7073
}
7174

7275
func TestNewEncryptorFromConfig_Errors(t *testing.T) {
73-
if _, err := NewEncryptorFromConfig(""); err == nil {
76+
if _, err := oauth.NewEncryptorFromConfig(""); err == nil {
7477
t.Fatalf("expected error for empty key")
7578
}
76-
if _, err := NewEncryptorFromConfig("not-a-valid-key!!"); err == nil {
79+
if _, err := oauth.NewEncryptorFromConfig("not-a-valid-key!!"); err == nil {
7780
t.Fatalf("expected error for invalid key encoding")
7881
}
79-
if _, err := NewEncryptor([]byte("tooshort")); err == nil {
82+
if _, err := oauth.NewEncryptor([]byte("tooshort")); err == nil {
8083
t.Fatalf("expected error for short raw key")
8184
}
8285
// Hex 32-byte key is accepted.
83-
if _, err := NewEncryptorFromConfig("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); err != nil {
86+
hexKey := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
87+
if _, err := oauth.NewEncryptorFromConfig(hexKey); err != nil {
8488
t.Fatalf("valid hex key rejected: %v", err)
8589
}
8690
}
91+
92+
func TestNewEncryptorFromConfig_HexKeyPrecedence(t *testing.T) {
93+
// A 32-char hex string is also valid base64; ensure it is interpreted as
94+
// raw hex bytes (16 bytes -> AES-128), not as 24 base64-decoded bytes.
95+
hexKey := "000102030405060708090a0b0c0d0e0f"
96+
enc, err := oauth.NewEncryptorFromConfig(hexKey)
97+
if err != nil {
98+
t.Fatalf("hex key rejected: %v", err)
99+
}
100+
101+
raw, derr := hex.DecodeString(hexKey)
102+
if derr != nil {
103+
t.Fatalf("internal: hex decode failed: %v", derr)
104+
}
105+
direct, derr := oauth.NewEncryptor(raw)
106+
if derr != nil {
107+
t.Fatalf("internal: direct encryptor failed: %v", derr)
108+
}
109+
110+
ct, eerr := enc.Encrypt("secret")
111+
if eerr != nil {
112+
t.Fatalf("encrypt failed: %v", eerr)
113+
}
114+
got, derr := direct.Decrypt(ct)
115+
if derr != nil {
116+
t.Fatalf("direct decrypt failed: %v", derr)
117+
}
118+
if got != "secret" {
119+
t.Fatalf("hex key not interpreted as raw bytes: got %q want %q", got, "secret")
120+
}
121+
}

internal/oauth/models.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ type tokenModel struct {
1111
bun.BaseModel `bun:"table:oauth_tokens,alias:ot"`
1212
db.TimedModel
1313

14-
ID int64 `bun:"id,pk,autoincrement"`
15-
UserID int64 `bun:"user_id"`
16-
AccessToken string `bun:"access_token,notnull"`
17-
RefreshToken string `bun:"refresh_token,notnull"`
18-
Scopes string `bun:"scopes,notnull"`
19-
ExpiresAt time.Time `bun:"expires_at"`
14+
ID int64 `bun:"id,pk,autoincrement"`
15+
UserID int64 `bun:"user_id"`
16+
AccessToken string `bun:"access_token,notnull"`
17+
RefreshToken string `bun:"refresh_token,notnull"`
18+
TokenFingerprint string `bun:"token_fingerprint,notnull"`
19+
Scopes string `bun:"scopes,notnull"`
20+
ExpiresAt time.Time `bun:"expires_at"`
2021
}
2122

2223
func (t *tokenModel) toDomain() *Token {
@@ -39,11 +40,12 @@ func newTokenModel(userID int64, token *Token) *tokenModel {
3940
UpdatedAt: token.UpdatedAt,
4041
},
4142

42-
ID: 0,
43-
UserID: userID,
44-
AccessToken: token.AccessToken,
45-
RefreshToken: token.RefreshToken,
46-
Scopes: token.Scopes,
47-
ExpiresAt: token.ExpiresAt,
43+
ID: 0,
44+
UserID: userID,
45+
AccessToken: token.AccessToken,
46+
RefreshToken: token.RefreshToken,
47+
TokenFingerprint: fingerprint(token.RefreshToken),
48+
Scopes: token.Scopes,
49+
ExpiresAt: token.ExpiresAt,
4850
}
4951
}

internal/oauth/module.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ func Module() fx.Option {
1313
logger.WithNamedLogger("oauth"),
1414
fx.Provide(
1515
func(cfg Config) (*Encryptor, error) {
16+
if !cfg.Enabled() {
17+
// OAuth is not configured: keep the connection disabled
18+
// instead of failing startup over a missing encryption key.
19+
return nil, nil
20+
}
1621
return NewEncryptorFromConfig(cfg.TokenEncryptionKey)
1722
},
1823
fx.Private,

internal/oauth/repository.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package oauth
22

33
import (
44
"context"
5+
"crypto/sha256"
56
"database/sql"
7+
"encoding/hex"
68
"errors"
79
"fmt"
810

@@ -34,7 +36,7 @@ func (r *Repository) Upsert(ctx context.Context, userID int64, token *Token) err
3436
model.AccessToken = encAccess
3537
model.RefreshToken = encRefresh
3638

37-
if _, err := r.db.NewInsert().Model(model).On("DUPLICATE KEY UPDATE").Exec(ctx); err != nil {
39+
if _, err = r.db.NewInsert().Model(model).On("DUPLICATE KEY UPDATE").Exec(ctx); err != nil {
3840
return fmt.Errorf("failed to upsert token: %w", err)
3941
}
4042

@@ -46,12 +48,11 @@ func (r *Repository) Upsert(ctx context.Context, userID int64, token *Token) err
4648
// no row matched, which happens if the token was deleted (or replaced) while
4749
// the refresh request was in flight.
4850
func (r *Repository) Update(ctx context.Context, userID int64, currentRefreshToken string, token *Token) (bool, error) {
49-
// The stored refresh token is ciphertext, so the optimistic-concurrency
50-
// match must compare against the encrypted current value.
51-
encCurrentRefresh, err := r.enc.Encrypt(currentRefreshToken)
52-
if err != nil {
53-
return false, fmt.Errorf("failed to encrypt current refresh token: %w", err)
54-
}
51+
// The stored refresh token is ciphertext with a fresh random nonce per
52+
// value, so it is never stable across calls. Use a deterministic fingerprint
53+
// of the plaintext refresh token for optimistic concurrency instead.
54+
currentFingerprint := fingerprint(currentRefreshToken)
55+
5556
encAccess, err := r.enc.Encrypt(token.AccessToken)
5657
if err != nil {
5758
return false, fmt.Errorf("failed to encrypt access token: %w", err)
@@ -65,11 +66,12 @@ func (r *Repository) Update(ctx context.Context, userID int64, currentRefreshTok
6566
Model((*tokenModel)(nil)).
6667
Set("access_token = ?", encAccess).
6768
Set("refresh_token = ?", encRefresh).
69+
Set("token_fingerprint = ?", fingerprint(token.RefreshToken)).
6870
Set("scopes = ?", token.Scopes).
6971
Set("expires_at = ?", token.ExpiresAt).
7072
Set("updated_at = ?", token.UpdatedAt).
7173
Where("user_id = ?", userID).
72-
Where("refresh_token = ?", encCurrentRefresh).
74+
Where("token_fingerprint = ?", currentFingerprint).
7375
Exec(ctx)
7476
if err != nil {
7577
return false, fmt.Errorf("failed to update token: %w", err)
@@ -118,3 +120,11 @@ func (r *Repository) Delete(ctx context.Context, userID int64) error {
118120
}
119121
return nil
120122
}
123+
124+
// fingerprint returns a stable SHA-256 hex digest of the plaintext refresh
125+
// token. Because AES-GCM seals with a random nonce, the stored ciphertext is
126+
// not suitable for optimistic-concurrency checks; the fingerprint is.
127+
func fingerprint(s string) string {
128+
sum := sha256.Sum256([]byte(s))
129+
return hex.EncodeToString(sum[:])
130+
}

0 commit comments

Comments
 (0)