Recent
diff --git a/frontend/src/lib/pages/admin.svelte b/frontend/src/lib/pages/admin.svelte
index 4746ab3..db295d5 100644
--- a/frontend/src/lib/pages/admin.svelte
+++ b/frontend/src/lib/pages/admin.svelte
@@ -1,5 +1,63 @@
+
+
+
+
Settings
+
+ Manage integrations and workspace settings
+
+
+
+
diff --git a/frontend/src/lib/types/api.ts b/frontend/src/lib/types/api.ts
index e9f9601..b61a158 100644
--- a/frontend/src/lib/types/api.ts
+++ b/frontend/src/lib/types/api.ts
@@ -63,6 +63,17 @@ export interface UserBrief {
created_at: string
}
+export interface BitbucketOAuthStatus {
+ connected: boolean
+ connected_at?: string
+ expires_at?: string
+ scopes?: string[]
+}
+
+export interface BitbucketOAuthAuthorizeResponse {
+ url: string
+}
+
export interface Task {
id: number
project_slug: string
diff --git a/go.mod b/go.mod
index a5b7e9a..cf7bfec 100644
--- a/go.mod
+++ b/go.mod
@@ -30,6 +30,7 @@ require (
go.uber.org/fx v1.24.0
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.53.0
+ golang.org/x/sync v0.21.0
)
require (
@@ -110,7 +111,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
- golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.46.0 // indirect
diff --git a/internal/commands/serve/serve.go b/internal/commands/serve/serve.go
index cbfdc2b..a3c8cab 100644
--- a/internal/commands/serve/serve.go
+++ b/internal/commands/serve/serve.go
@@ -9,6 +9,7 @@ import (
"github.com/bit-issues/backend/internal/config"
"github.com/bit-issues/backend/internal/db"
"github.com/bit-issues/backend/internal/jwt"
+ "github.com/bit-issues/backend/internal/oauth"
"github.com/bit-issues/backend/internal/projects"
"github.com/bit-issues/backend/internal/server"
"github.com/bit-issues/backend/internal/storage"
@@ -75,12 +76,13 @@ func run(ctx context.Context, version healthfx.Version) error {
//
// BUSINESS MODULES
fx.Supply(version),
+ attachments.Module(),
+ comments.Module(),
jwt.Module(),
- users.Module(),
+ oauth.Module(),
projects.Module(),
tasks.Module(),
- attachments.Module(),
- comments.Module(),
+ users.Module(),
webauthn.Module(),
webhooks.Module(),
//
diff --git a/internal/config/config.go b/internal/config/config.go
index 01b1ebb..97cb63d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -63,6 +63,12 @@ type webhooksConfig struct {
ActionKeywords map[string]webhooks.KeywordEntry `koanf:"action_keywords"`
}
+type oauthConfig struct {
+ ClientID string `koanf:"client_id"`
+ ClientSecret string `koanf:"client_secret"`
+ TokenEncryptionKey string `koanf:"token_encryption_key"`
+}
+
type Config struct {
HTTP http `koanf:"http"`
Database databaseConfig `koanf:"database"`
@@ -72,6 +78,7 @@ type Config struct {
WebAuthn webauthnConfig `koanf:"webauthn"`
Cache cacheConfig `koanf:"cache"`
Webhooks webhooksConfig `koanf:"webhooks"`
+ OAuth oauthConfig `koanf:"oauth"`
}
func Default() Config {
@@ -134,6 +141,11 @@ func Default() Config {
"on hold": {Status: "On Hold", Verb: "On Hold"},
},
},
+ OAuth: oauthConfig{
+ ClientID: "",
+ ClientSecret: "",
+ TokenEncryptionKey: "",
+ },
}
}
diff --git a/internal/config/module.go b/internal/config/module.go
index 6c50773..b3b3b11 100644
--- a/internal/config/module.go
+++ b/internal/config/module.go
@@ -3,6 +3,7 @@ package config
import (
"github.com/bit-issues/backend/internal/attachments"
"github.com/bit-issues/backend/internal/jwt"
+ "github.com/bit-issues/backend/internal/oauth"
"github.com/bit-issues/backend/internal/storage"
"github.com/bit-issues/backend/internal/webauthn"
"github.com/bit-issues/backend/internal/webhooks"
@@ -41,6 +42,11 @@ func Module() fx.Option {
MaxIdleConns: cfg.Database.MaxIdleConns,
}
},
+ func(cfg Config) cachefx.Config {
+ return cachefx.Config{
+ URL: cfg.Cache.URL,
+ }
+ },
),
fx.Provide(
func(cfg Config) jwt.Config {
@@ -57,15 +63,11 @@ func Module() fx.Option {
LinksTTL: cfg.Storage.LinksTTL,
}
},
- ),
- fx.Provide(
func(cfg Config) attachments.Config {
return attachments.Config{
MaxSize: cfg.Attachments.MaxSize,
}
},
- ),
- fx.Provide(
func(cfg Config) webhooks.Config {
return webhooks.Config{
Secret: cfg.Webhooks.Secret,
@@ -73,8 +75,6 @@ func Module() fx.Option {
ActionKeywords: cfg.Webhooks.ActionKeywords,
}
},
- ),
- fx.Provide(
func(cfg Config) webauthn.Config {
return webauthn.Config{
RPDisplayName: cfg.WebAuthn.RPDisplayName,
@@ -82,11 +82,11 @@ func Module() fx.Option {
RPOrigins: cfg.WebAuthn.RPOrigins,
}
},
- ),
- fx.Provide(
- func(cfg Config) cachefx.Config {
- return cachefx.Config{
- URL: cfg.Cache.URL,
+ func(cfg Config) oauth.Config {
+ return oauth.Config{
+ ClientID: cfg.OAuth.ClientID,
+ ClientSecret: cfg.OAuth.ClientSecret,
+ TokenEncryptionKey: cfg.OAuth.TokenEncryptionKey,
}
},
),
diff --git a/internal/db/migrations/20260825050007_oauth_tokens.sql b/internal/db/migrations/20260825050007_oauth_tokens.sql
new file mode 100644
index 0000000..1163ac5
--- /dev/null
+++ b/internal/db/migrations/20260825050007_oauth_tokens.sql
@@ -0,0 +1,22 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE `oauth_tokens` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `access_token` TEXT NOT NULL,
+ `refresh_token` TEXT NOT NULL,
+ `token_fingerprint` VARCHAR(64) NOT NULL,
+ `scopes` VARCHAR(255) NOT NULL,
+ `expires_at` DATETIME NOT NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `user_id` (`user_id`),
+ CONSTRAINT `fk_oauth_tokens_users` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
+);
+-- +goose StatementEnd
+---
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE `oauth_tokens`;
+-- +goose StatementEnd
\ No newline at end of file
diff --git a/internal/oauth/config.go b/internal/oauth/config.go
new file mode 100644
index 0000000..690eabf
--- /dev/null
+++ b/internal/oauth/config.go
@@ -0,0 +1,19 @@
+package oauth
+
+// Config holds OAuth service tunables.
+type Config struct {
+ // ClientID is the Bitbucket OAuth app consumer key.
+ ClientID string
+ // ClientSecret is the Bitbucket OAuth app consumer secret. Never logged.
+ ClientSecret string
+ // TokenEncryptionKey is the AES key (hex or base64, 16/24/32 bytes) used to
+ // encrypt the stored OAuth access and refresh tokens at rest. Required.
+ TokenEncryptionKey string
+}
+
+// enabled reports whether OAuth is fully configured. All of the Bitbucket
+// client credentials and the token encryption key must be present; otherwise
+// the OAuth connection stays disabled and startup must not fail.
+func (c Config) enabled() bool {
+ return c.ClientID != "" && c.ClientSecret != ""
+}
diff --git a/internal/oauth/consts.go b/internal/oauth/consts.go
new file mode 100644
index 0000000..9556c6b
--- /dev/null
+++ b/internal/oauth/consts.go
@@ -0,0 +1,15 @@
+package oauth
+
+import "time"
+
+const (
+ // defaultAuthorizeURL is the public Bitbucket OAuth authorization endpoint.
+ defaultAuthorizeURL = "https://bitbucket.org/site/oauth2/authorize"
+ // defaultTokenURL is the public Bitbucket OAuth token endpoint.
+ defaultTokenURL = "https://bitbucket.org/site/oauth2/access_token" //nolint:gosec // endpoint path, not a credential
+ // requiredScope is the minimum Bitbucket OAuth scope for webhook management.
+ requiredScope = "webhook"
+
+ // defaultRefreshThreshold is the maximum time between token refreshes.
+ defaultRefreshThreshold = 15 * time.Minute
+)
diff --git a/internal/oauth/crypto.go b/internal/oauth/crypto.go
new file mode 100644
index 0000000..cec15b3
--- /dev/null
+++ b/internal/oauth/crypto.go
@@ -0,0 +1,101 @@
+package oauth
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+)
+
+// ErrInvalidKey indicates the configured OAuth token encryption key is missing
+// or has an unsupported length.
+var ErrInvalidKey = errors.New("invalid oauth token encryption key")
+
+// ErrInvalidCiphertext indicates stored token ciphertext could not be
+// authenticated or decoded.
+var ErrInvalidCiphertext = errors.New("invalid oauth token ciphertext")
+
+// Encryptor performs authenticated encryption (AES-GCM) of OAuth tokens at
+// rest. A random 12-byte nonce is prepended to the sealed payload; the
+// combined bytes are base64-encoded for storage in a text column. Decryption
+// fails closed (returns an error) when the ciphertext is tampered with.
+type Encryptor struct {
+ gcm cipher.AEAD
+}
+
+// NewEncryptor builds an Encryptor from a raw AES key. The key must be 16, 24,
+// or 32 bytes (AES-128/192/256).
+func NewEncryptor(key []byte) (*Encryptor, error) {
+ if l := len(key); l != 16 && l != 24 && l != 32 {
+ return nil, fmt.Errorf("%w: must be 16, 24, or 32 bytes, got %d", ErrInvalidKey, l)
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create cipher: %w", err)
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create gcm: %w", err)
+ }
+ return &Encryptor{gcm: gcm}, nil
+}
+
+// NewEncryptorFromConfig decodes a key from configuration. Both hex and base64
+// (standard) encodings are accepted; the decoded key must be a valid AES
+// length. An empty key is rejected so tokens are never persisted plaintext.
+func NewEncryptorFromConfig(encoded string) (*Encryptor, error) {
+ if encoded == "" {
+ return nil, fmt.Errorf("%w: encryption key is required", ErrInvalidKey)
+ }
+ for _, dec := range []func(string) ([]byte, error){
+ hex.DecodeString,
+ func(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) },
+ } {
+ if raw, derr := dec(encoded); derr == nil {
+ if enc, nerr := NewEncryptor(raw); nerr == nil {
+ return enc, nil
+ }
+ }
+ }
+ return nil, fmt.Errorf("%w: key must be 16/24/32-byte hex or base64", ErrInvalidKey)
+}
+
+// Encrypt seals plaintext with AES-GCM and returns base64(nonce || ciphertext).
+// Empty input round-trips to an empty string (no nonce is generated).
+func (e *Encryptor) Encrypt(plaintext string) (string, error) {
+ if plaintext == "" {
+ return "", nil
+ }
+ nonce := make([]byte, e.gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", fmt.Errorf("failed to generate nonce: %w", err)
+ }
+ sealed := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
+ return base64.StdEncoding.EncodeToString(sealed), nil
+}
+
+// Decrypt reverses Encrypt, failing closed on any authentication or decode
+// error so a tampered or unreadable token is never returned as plaintext.
+func (e *Encryptor) Decrypt(ciphertext string) (string, error) {
+ if ciphertext == "" {
+ return "", nil
+ }
+ raw, err := base64.StdEncoding.DecodeString(ciphertext)
+ if err != nil {
+ return "", fmt.Errorf("%w: %w", ErrInvalidCiphertext, err)
+ }
+ ns := e.gcm.NonceSize()
+ if len(raw) < ns {
+ return "", fmt.Errorf("%w: ciphertext shorter than nonce", ErrInvalidCiphertext)
+ }
+ nonce, ct := raw[:ns], raw[ns:]
+ plain, err := e.gcm.Open(nil, nonce, ct, nil)
+ if err != nil {
+ return "", fmt.Errorf("%w: %w", ErrInvalidCiphertext, err)
+ }
+ return string(plain), nil
+}
diff --git a/internal/oauth/crypto_test.go b/internal/oauth/crypto_test.go
new file mode 100644
index 0000000..0024478
--- /dev/null
+++ b/internal/oauth/crypto_test.go
@@ -0,0 +1,121 @@
+package oauth_test
+
+import (
+ "encoding/hex"
+ "strings"
+ "testing"
+
+ "github.com/bit-issues/backend/internal/oauth"
+)
+
+func testEncryptor(t *testing.T) *oauth.Encryptor {
+ t.Helper()
+ enc, err := oauth.NewEncryptorFromConfig("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // 32-byte base64
+ if err != nil {
+ t.Fatalf("failed to build encryptor: %v", err)
+ }
+ return enc
+}
+
+func TestEncryptor_RoundTrip(t *testing.T) {
+ enc := testEncryptor(t)
+
+ cases := []string{
+ "secret-access-token",
+ "refresh-token-with-symbols-/+=",
+ strings.Repeat("x", 500),
+ }
+ for _, plain := range cases {
+ ct, err := enc.Encrypt(plain)
+ if err != nil {
+ t.Fatalf("encrypt failed: %v", err)
+ }
+ if ct == plain {
+ t.Fatalf("ciphertext equals plaintext: %q", plain)
+ }
+ got, err := enc.Decrypt(ct)
+ if err != nil {
+ t.Fatalf("decrypt failed: %v", err)
+ }
+ if got != plain {
+ t.Fatalf("round trip mismatch: got %q want %q", got, plain)
+ }
+ }
+}
+
+func TestEncryptor_Empty(t *testing.T) {
+ enc := testEncryptor(t)
+ ct, err := enc.Encrypt("")
+ if err != nil || ct != "" {
+ t.Fatalf("empty encrypt: got %q err %v", ct, err)
+ }
+ got, err := enc.Decrypt("")
+ if err != nil || got != "" {
+ t.Fatalf("empty decrypt: got %q err %v", got, err)
+ }
+}
+
+func TestEncryptor_TamperFails(t *testing.T) {
+ enc := testEncryptor(t)
+ ct, err := enc.Encrypt("top-secret")
+ if err != nil {
+ t.Fatalf("encrypt failed: %v", err)
+ }
+ tampered := ct
+ if tampered[0] == 'A' {
+ tampered = "B" + tampered[1:]
+ } else {
+ tampered = "A" + tampered[1:]
+ }
+ if _, derr := enc.Decrypt(tampered); derr == nil {
+ t.Fatalf("expected decryption failure on tampered ciphertext")
+ }
+}
+
+func TestNewEncryptorFromConfig_Errors(t *testing.T) {
+ if _, err := oauth.NewEncryptorFromConfig(""); err == nil {
+ t.Fatalf("expected error for empty key")
+ }
+ if _, err := oauth.NewEncryptorFromConfig("not-a-valid-key!!"); err == nil {
+ t.Fatalf("expected error for invalid key encoding")
+ }
+ if _, err := oauth.NewEncryptor([]byte("tooshort")); err == nil {
+ t.Fatalf("expected error for short raw key")
+ }
+ // Hex 32-byte key is accepted.
+ hexKey := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
+ if _, err := oauth.NewEncryptorFromConfig(hexKey); err != nil {
+ t.Fatalf("valid hex key rejected: %v", err)
+ }
+}
+
+func TestNewEncryptorFromConfig_HexKeyPrecedence(t *testing.T) {
+ // A 32-char hex string is also valid base64; ensure it is interpreted as
+ // raw hex bytes (16 bytes -> AES-128), not as 24 base64-decoded bytes.
+ hexKey := "000102030405060708090a0b0c0d0e0f"
+ enc, err := oauth.NewEncryptorFromConfig(hexKey)
+ if err != nil {
+ t.Fatalf("hex key rejected: %v", err)
+ }
+
+ raw, derr := hex.DecodeString(hexKey)
+ if derr != nil {
+ t.Fatalf("internal: hex decode failed: %v", derr)
+ }
+ direct, derr := oauth.NewEncryptor(raw)
+ if derr != nil {
+ t.Fatalf("internal: direct encryptor failed: %v", derr)
+ }
+
+ ct, eerr := enc.Encrypt("secret")
+ if eerr != nil {
+ t.Fatalf("encrypt failed: %v", eerr)
+ }
+ got, derr := direct.Decrypt(ct)
+ if derr != nil {
+ t.Fatalf("direct decrypt failed: %v", derr)
+ }
+ if got != "secret" {
+ t.Fatalf("hex key not interpreted as raw bytes: got %q want %q", got, "secret")
+ }
+}
diff --git a/internal/oauth/domain.go b/internal/oauth/domain.go
new file mode 100644
index 0000000..4a292e3
--- /dev/null
+++ b/internal/oauth/domain.go
@@ -0,0 +1,69 @@
+package oauth
+
+import (
+ "fmt"
+ "time"
+)
+
+// Token is the domain representation of the stored Bitbucket OAuth credential.
+// The AccessToken and RefreshToken here are always the decrypted plaintext;
+// they are encrypted at rest by the repository before persistence.
+type Token struct {
+ AccessToken string
+ RefreshToken string
+ Scopes string
+ ExpiresAt time.Time
+
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type EncryptedToken struct {
+ Token
+
+ Fingerprint string
+}
+
+func NewEncryptedToken(enc *Encryptor, token Token) (*EncryptedToken, error) {
+ fp := fingerprint(token.RefreshToken)
+
+ var err error
+ if enc != nil {
+ token.AccessToken, err = enc.Encrypt(token.AccessToken)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encrypt access token: %w", err)
+ }
+ token.RefreshToken, err = enc.Encrypt(token.RefreshToken)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encrypt refresh token: %w", err)
+ }
+ }
+
+ return &EncryptedToken{
+ Token: token,
+ Fingerprint: fp,
+ }, nil
+}
+
+func (f *EncryptedToken) toToken(enc *Encryptor) (*Token, error) {
+ if f == nil {
+ return nil, nil //nolint:nilnil //empty value
+ }
+
+ if enc == nil {
+ return &f.Token, nil
+ }
+
+ var err error
+ token := f.Token
+ token.AccessToken, err = enc.Decrypt(f.AccessToken)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decrypt access token: %w", err)
+ }
+ token.RefreshToken, err = enc.Decrypt(f.RefreshToken)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decrypt refresh token: %w", err)
+ }
+
+ return &token, nil
+}
diff --git a/internal/oauth/dto.go b/internal/oauth/dto.go
new file mode 100644
index 0000000..726f3a2
--- /dev/null
+++ b/internal/oauth/dto.go
@@ -0,0 +1,9 @@
+package oauth
+
+// tokenResponse mirrors the Bitbucket OAuth token endpoint payload.
+type tokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ Scopes string `json:"scopes"`
+ ExpiresIn int `json:"expires_in"`
+}
diff --git a/internal/oauth/errors.go b/internal/oauth/errors.go
new file mode 100644
index 0000000..773e44e
--- /dev/null
+++ b/internal/oauth/errors.go
@@ -0,0 +1,19 @@
+package oauth
+
+import "errors"
+
+var (
+ // ErrDisabled is returned when the OAuth service is disabled.
+ ErrDisabled = errors.New("oauth service disabled")
+
+ // ErrTokenIssueFailed is returned when the Bitbucket OAuth token
+ // endpoint rejects an authorization code or refresh token grant.
+ ErrTokenIssueFailed = errors.New("oauth token issue failed")
+
+ // ErrNotFound is returned when a token is not found.
+ ErrNotFound = errors.New("token not found")
+
+ // ErrStateNotFound is returned when an OAuth CSRF state is missing,
+ // expired, or already consumed.
+ ErrStateNotFound = errors.New("oauth state not found")
+)
diff --git a/internal/oauth/export_test.go b/internal/oauth/export_test.go
new file mode 100644
index 0000000..336d663
--- /dev/null
+++ b/internal/oauth/export_test.go
@@ -0,0 +1,7 @@
+package oauth
+
+// GenerateState exposes generateState to the external test package. This file is
+// only compiled during testing, so it does not expand the production API.
+func GenerateState() (string, error) {
+ return generateState()
+}
diff --git a/internal/oauth/models.go b/internal/oauth/models.go
new file mode 100644
index 0000000..84831d6
--- /dev/null
+++ b/internal/oauth/models.go
@@ -0,0 +1,60 @@
+package oauth
+
+import (
+ "time"
+
+ "github.com/bit-issues/backend/internal/db"
+ "github.com/uptrace/bun"
+)
+
+type tokenModel struct {
+ bun.BaseModel `bun:"table:oauth_tokens,alias:ot"`
+ db.TimedModel
+
+ ID int64 `bun:"id,pk,autoincrement"`
+ UserID int64 `bun:"user_id"`
+ AccessToken string `bun:"access_token,notnull"`
+ RefreshToken string `bun:"refresh_token,notnull"`
+ TokenFingerprint string `bun:"token_fingerprint,notnull"`
+ Scopes string `bun:"scopes,notnull"`
+ ExpiresAt time.Time `bun:"expires_at"`
+}
+
+func (t *tokenModel) toDomain() *EncryptedToken {
+ if t == nil {
+ return nil
+ }
+
+ return &EncryptedToken{
+ Token: Token{
+ AccessToken: t.AccessToken,
+ RefreshToken: t.RefreshToken,
+
+ ExpiresAt: t.ExpiresAt,
+ Scopes: t.Scopes,
+
+ CreatedAt: t.CreatedAt,
+ UpdatedAt: t.UpdatedAt,
+ },
+
+ Fingerprint: t.TokenFingerprint,
+ }
+}
+
+func newTokenModel(userID int64, token EncryptedToken) *tokenModel {
+ return &tokenModel{
+ BaseModel: bun.BaseModel{},
+ TimedModel: db.TimedModel{
+ CreatedAt: token.CreatedAt,
+ UpdatedAt: token.UpdatedAt,
+ },
+
+ ID: 0,
+ UserID: userID,
+ AccessToken: token.AccessToken,
+ RefreshToken: token.RefreshToken,
+ TokenFingerprint: token.Fingerprint,
+ Scopes: token.Scopes,
+ ExpiresAt: token.ExpiresAt,
+ }
+}
diff --git a/internal/oauth/module.go b/internal/oauth/module.go
new file mode 100644
index 0000000..5295a28
--- /dev/null
+++ b/internal/oauth/module.go
@@ -0,0 +1,23 @@
+package oauth
+
+import (
+ "github.com/go-core-fx/cachefx"
+ "github.com/go-core-fx/cachefx/cache"
+ "github.com/go-core-fx/logger"
+ "go.uber.org/fx"
+)
+
+func Module() fx.Option {
+ return fx.Module(
+ "oauth",
+ logger.WithNamedLogger("oauth"),
+ fx.Provide(
+ func(factory cachefx.Factory) (cache.Cache, error) {
+ return factory.New("oauth")
+ },
+ fx.Private,
+ ),
+ fx.Provide(NewRepository, fx.Private),
+ fx.Provide(NewService),
+ )
+}
diff --git a/internal/oauth/repository.go b/internal/oauth/repository.go
new file mode 100644
index 0000000..a316861
--- /dev/null
+++ b/internal/oauth/repository.go
@@ -0,0 +1,99 @@
+package oauth
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "errors"
+ "fmt"
+
+ "github.com/uptrace/bun"
+)
+
+// Repository persists the per-user OAuth token row. Access and refresh tokens
+// are encrypted at rest via enc before being written, and decrypted on read.
+type Repository struct {
+ db *bun.DB
+}
+
+func NewRepository(db *bun.DB) *Repository {
+ return &Repository{db: db}
+}
+
+func (r *Repository) Upsert(ctx context.Context, userID int64, token EncryptedToken) error {
+ model := newTokenModel(userID, token)
+
+ if _, err := r.db.NewInsert().Model(model).On("DUPLICATE KEY UPDATE").Exec(ctx); err != nil {
+ return fmt.Errorf("failed to upsert token: %w", err)
+ }
+
+ return nil
+}
+
+// Update persists a refreshed token only if the credential loaded before the
+// refresh still exists and matches currentFingerprint. It returns false when
+// no row matched, which happens if the token was deleted (or replaced) while
+// the refresh request was in flight.
+func (r *Repository) Update(
+ ctx context.Context,
+ userID int64,
+ currentFingerprint string,
+ token EncryptedToken,
+) (bool, error) {
+ res, err := r.db.NewUpdate().
+ Model((*tokenModel)(nil)).
+ Set("access_token = ?", token.AccessToken).
+ Set("refresh_token = ?", token.RefreshToken).
+ Set("token_fingerprint = ?", token.Fingerprint).
+ Set("scopes = ?", token.Scopes).
+ Set("expires_at = ?", token.ExpiresAt).
+ Set("updated_at = ?", token.UpdatedAt).
+ Where("user_id = ?", userID).
+ Where("token_fingerprint = ?", currentFingerprint).
+ Exec(ctx)
+ if err != nil {
+ return false, fmt.Errorf("failed to update token: %w", err)
+ }
+
+ n, err := res.RowsAffected()
+ if err != nil {
+ return false, fmt.Errorf("failed to read affected rows: %w", err)
+ }
+
+ return n > 0, nil
+}
+
+func (r *Repository) Get(ctx context.Context, userID int64) (*EncryptedToken, error) {
+ var model tokenModel
+ if err := r.db.NewSelect().Model(&model).
+ Where("user_id = ?", userID).
+ Limit(1).
+ Scan(ctx); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, fmt.Errorf("failed to get token: %w", err)
+ }
+
+ token := model.toDomain()
+
+ return token, nil
+}
+
+func (r *Repository) Delete(ctx context.Context, userID int64) error {
+ if _, err := r.db.NewDelete().Model((*tokenModel)(nil)).
+ Where("user_id = ?", userID).
+ Exec(ctx); err != nil {
+ return fmt.Errorf("failed to delete token: %w", err)
+ }
+ return nil
+}
+
+// fingerprint returns a stable SHA-256 hex digest of the plaintext refresh
+// token. Because AES-GCM seals with a random nonce, the stored ciphertext is
+// not suitable for optimistic-concurrency checks; the fingerprint is.
+func fingerprint(s string) string {
+ sum := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(sum[:])
+}
diff --git a/internal/oauth/service.go b/internal/oauth/service.go
new file mode 100644
index 0000000..a1a2ea7
--- /dev/null
+++ b/internal/oauth/service.go
@@ -0,0 +1,239 @@
+package oauth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-core-fx/cachefx/cache"
+ "go.uber.org/zap"
+ "golang.org/x/sync/singleflight"
+)
+
+// singleflightKey serializes every refresh attempt, including the read and
+// persist of the singleton row, so concurrent GetToken calls share one result.
+const singleflightKey = "oauth-token-refresh"
+
+// Service stores the per-user Bitbucket OAuth credential and manages the
+// connection flow. CSRF states are persisted in a cache-backed store.
+type Service struct {
+ cfg Config
+ tokens *Repository
+ enc *Encryptor
+ states *stateStore
+
+ group singleflight.Group
+ http *http.Client
+
+ logger *zap.Logger
+}
+
+func NewService(
+ cfg Config,
+ tokens *Repository,
+ backend cache.Cache,
+
+ logger *zap.Logger,
+) (*Service, error) {
+ var enc *Encryptor
+ if cfg.TokenEncryptionKey != "" {
+ var err error
+ enc, err = NewEncryptorFromConfig(cfg.TokenEncryptionKey)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return &Service{
+ cfg: cfg,
+ tokens: tokens,
+ enc: enc,
+ states: newStateStore(backend),
+
+ group: singleflight.Group{},
+ http: http.DefaultClient,
+
+ logger: logger,
+ }, nil
+}
+
+func (s *Service) AuthorizeURL(ctx context.Context, userID int64) (string, error) {
+ if !s.cfg.enabled() {
+ return "", ErrDisabled
+ }
+
+ state, err := generateState()
+ if err != nil {
+ return "", err
+ }
+ if saveErr := s.states.Save(ctx, state, userID); saveErr != nil {
+ return "", saveErr
+ }
+
+ query := url.Values{}
+ query.Set("client_id", s.cfg.ClientID)
+ query.Set("response_type", "code")
+ query.Set("scope", requiredScope)
+ query.Set("state", state)
+
+ return defaultAuthorizeURL + "?" + query.Encode(), nil
+}
+
+func (s *Service) Exchange(ctx context.Context, state, code string) error {
+ if !s.cfg.enabled() {
+ return ErrDisabled
+ }
+
+ // Consume the single-use CSRF state to recover the initiating user. This
+ // binds the stored token to the admin who started the connection and
+ // rejects forged, expired, or replayed states.
+ userID, err := s.states.Consume(ctx, state)
+ if err != nil {
+ return err
+ }
+
+ form := url.Values{}
+ form.Set("grant_type", "authorization_code")
+ form.Set("code", code)
+
+ token, err := s.requestToken(ctx, form)
+ if err != nil {
+ return err
+ }
+
+ encToken, err := NewEncryptedToken(s.enc, *token)
+ if err != nil {
+ return err
+ }
+
+ return s.tokens.Upsert(ctx, userID, *encToken)
+}
+
+func (s *Service) GetToken(ctx context.Context, userID int64) (*Token, error) {
+ if !s.cfg.enabled() {
+ return nil, ErrDisabled
+ }
+
+ value, err, _ := s.group.Do(singleflightKey+strconv.FormatInt(userID, 10), func() (any, error) {
+ token, getErr := s.tokens.Get(ctx, userID)
+ if getErr != nil {
+ return nil, getErr
+ }
+
+ if time.Now().Add(defaultRefreshThreshold).Before(token.ExpiresAt) {
+ return token.toToken(s.enc)
+ }
+
+ stored, err := token.toToken(s.enc)
+ if err != nil {
+ return nil, err
+ }
+
+ refreshed, refreshErr := s.refreshLocked(ctx, userID, stored)
+ if refreshErr != nil {
+ return nil, refreshErr
+ }
+
+ return refreshed, nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to get oauth token: %w", err)
+ }
+
+ token, ok := value.(*Token)
+ if !ok {
+ return nil, ErrTokenIssueFailed
+ }
+
+ return token, nil
+}
+
+func (s *Service) DeleteToken(ctx context.Context, userID int64) error {
+ if !s.cfg.enabled() {
+ return ErrDisabled
+ }
+
+ return s.tokens.Delete(ctx, userID)
+}
+
+// requestToken performs an OAuth token grant. The client authenticates with
+// HTTP Basic credentials. Errors never include upstream bodies or tokens.
+func (s *Service) requestToken(ctx context.Context, form url.Values) (*Token, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, defaultTokenURL, strings.NewReader(form.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create oauth token request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.SetBasicAuth(s.cfg.ClientID, s.cfg.ClientSecret)
+
+ resp, err := s.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("oauth token request failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read oauth token response: %w", err)
+ }
+
+ var parsed tokenResponse
+ if err = json.Unmarshal(body, &parsed); err != nil {
+ return nil, fmt.Errorf("failed to parse oauth token response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
+ return nil, fmt.Errorf("%w: Bitbucket returned status %d", ErrTokenIssueFailed, resp.StatusCode)
+ }
+
+ now := time.Now()
+ token := &Token{
+ AccessToken: parsed.AccessToken,
+ RefreshToken: parsed.RefreshToken,
+ Scopes: parsed.Scopes,
+ ExpiresAt: time.Time{},
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if parsed.ExpiresIn > 0 {
+ token.ExpiresAt = time.Now().Add(time.Duration(parsed.ExpiresIn) * time.Second)
+ }
+
+ return token, nil
+}
+
+// refreshLocked runs a single refresh cycle. Callers must hold the
+// singleflight lock (GetToken wraps this call).
+func (s *Service) refreshLocked(ctx context.Context, userID int64, current *Token) (*Token, error) {
+ s.logger.Info("refreshing oauth access token")
+
+ form := url.Values{}
+ form.Set("grant_type", "refresh_token")
+ form.Set("refresh_token", current.RefreshToken)
+
+ refreshed, err := s.requestToken(ctx, form)
+ if err != nil {
+ return nil, fmt.Errorf("failed to refresh oauth token: %w", err)
+ }
+
+ encToken, err := NewEncryptedToken(s.enc, *refreshed)
+ if err != nil {
+ return nil, err
+ }
+
+ ok, err := s.tokens.Update(ctx, userID, fingerprint(current.RefreshToken), *encToken)
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ return nil, ErrNotFound
+ }
+
+ return refreshed, nil
+}
diff --git a/internal/oauth/states.go b/internal/oauth/states.go
new file mode 100644
index 0000000..6f05cad
--- /dev/null
+++ b/internal/oauth/states.go
@@ -0,0 +1,75 @@
+package oauth
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/go-core-fx/cachefx/cache"
+)
+
+const (
+ stateTTL = 10 * time.Minute
+ stateBytes = 32
+)
+
+type stateEntry struct {
+ UserID int64 `json:"user_id"`
+}
+
+func (e *stateEntry) Marshal() ([]byte, error) {
+ b, err := json.Marshal(e)
+ if err != nil {
+ return nil, fmt.Errorf("marshal oauth state: %w", err)
+ }
+ return b, nil
+}
+
+func (e *stateEntry) Unmarshal(data []byte) error {
+ if err := json.Unmarshal(data, e); err != nil {
+ return fmt.Errorf("unmarshal oauth state: %w", err)
+ }
+ return nil
+}
+
+type stateStore struct {
+ storage *cache.Typed[*stateEntry]
+}
+
+func newStateStore(c cache.Cache) *stateStore {
+ return &stateStore{storage: cache.NewTyped[*stateEntry](c)}
+}
+
+// Save persists a single-use CSRF state bound to the initiating user.
+func (s *stateStore) Save(ctx context.Context, state string, userID int64) error {
+ if err := s.storage.Set(ctx, state, &stateEntry{UserID: userID}, cache.WithTTL(stateTTL)); err != nil {
+ return fmt.Errorf("failed to store oauth state: %w", err)
+ }
+ return nil
+}
+
+// Consume reads and deletes the state, returning the bound user. A missing or
+// expired state yields ErrStateNotFound, which rejects forged/replayed flows.
+func (s *stateStore) Consume(ctx context.Context, state string) (int64, error) {
+ entry, err := s.storage.Get(ctx, state, cache.AndDelete())
+ if err != nil {
+ if errors.Is(err, cache.ErrKeyNotFound) {
+ return 0, ErrStateNotFound
+ }
+ return 0, fmt.Errorf("failed to load oauth state: %w", err)
+ }
+ return entry.UserID, nil
+}
+
+// generateState returns a cryptographically random, unguessable state value.
+func generateState() (string, error) {
+ b := make([]byte, stateBytes)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("failed to generate oauth state: %w", err)
+ }
+ return hex.EncodeToString(b), nil
+}
diff --git a/internal/oauth/states_test.go b/internal/oauth/states_test.go
new file mode 100644
index 0000000..af01519
--- /dev/null
+++ b/internal/oauth/states_test.go
@@ -0,0 +1,24 @@
+package oauth_test
+
+import (
+ "testing"
+
+ "github.com/bit-issues/backend/internal/oauth"
+)
+
+func TestGenerateStateUnique(t *testing.T) {
+ seen := make(map[string]struct{}, 100)
+ for range 100 {
+ s, err := oauth.GenerateState()
+ if err != nil {
+ t.Fatalf("GenerateState: %v", err)
+ }
+ if len(s) != 64 { // 32 bytes -> 64 hex chars
+ t.Fatalf("unexpected state length: %d", len(s))
+ }
+ if _, ok := seen[s]; ok {
+ t.Fatal("duplicate state generated")
+ }
+ seen[s] = struct{}{}
+ }
+}
diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go
index 9e80558..bd5c7b3 100644
--- a/internal/server/docs/docs.go
+++ b/internal/server/docs/docs.go
@@ -532,6 +532,157 @@ const docTemplate = `{
}
}
},
+ "/oauth/bitbucket/authorize": {
+ "get": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Generates a CSRF state and returns the Bitbucket authorization URL. Only administrators can perform this action.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "OAuth"
+ ],
+ "summary": "Start Bitbucket OAuth connection",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/oauth.AuthorizeResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/fiberfx.ErrorResponse"
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "$ref": "#/definitions/fiberfx.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/oauth/bitbucket/callback": {
+ "get": {
+ "description": "Public callback that exchanges the authorization code with Bitbucket, stores the tokens, and redirects to the admin settings page.",
+ "tags": [
+ "OAuth"
+ ],
+ "summary": "Bitbucket OAuth callback",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Authorization code",
+ "name": "code",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "State",
+ "name": "state",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Bitbucket OAuth error code",
+ "name": "error",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "302": {
+ "description": "Found"
+ }
+ }
+ }
+ },
+ "/oauth/bitbucket/disconnect": {
+ "post": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Removes the stored Bitbucket OAuth credential. Remote repository webhooks are NOT removed; they stop working after the token expires. Only administrators can perform this action.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "OAuth"
+ ],
+ "summary": "Disconnect Bitbucket OAuth",
+ "responses": {
+ "204": {
+ "description": "No Content"
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/fiberfx.ErrorResponse"
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "$ref": "#/definitions/fiberfx.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/oauth/bitbucket/status": {
+ "get": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns whether a Bitbucket OAuth credential is stored. Only administrators can perform this action.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "OAuth"
+ ],
+ "summary": "Bitbucket OAuth connection status",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/oauth.StatusResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/fiberfx.ErrorResponse"
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "$ref": "#/definitions/fiberfx.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
"/projects": {
"get": {
"security": [
@@ -2174,6 +2325,40 @@ const docTemplate = `{
}
}
},
+ "oauth.AuthorizeResponse": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "example": "https://bitbucket.org/site/oauth2/authorize?client_id=...\u0026response_type=code\u0026scope=webhook\u0026state=...\u0026redirect_uri=..."
+ }
+ }
+ },
+ "oauth.StatusResponse": {
+ "type": "object",
+ "properties": {
+ "connected": {
+ "type": "boolean"
+ },
+ "connected_at": {
+ "type": "string",
+ "example": "2026-08-24T12:00:00Z"
+ },
+ "expires_at": {
+ "type": "string",
+ "example": "2026-08-24T14:00:00Z"
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "webhook"
+ ]
+ }
+ }
+ },
"passkey.CredentialResponse": {
"type": "object",
"properties": {
diff --git a/internal/server/module.go b/internal/server/module.go
index 73f0e54..6b45b2f 100644
--- a/internal/server/module.go
+++ b/internal/server/module.go
@@ -4,6 +4,7 @@ import (
"github.com/bit-issues/backend/internal/server/auth"
"github.com/bit-issues/backend/internal/server/docs"
"github.com/bit-issues/backend/internal/server/middlewares/jwtauth"
+ "github.com/bit-issues/backend/internal/server/oauth"
"github.com/bit-issues/backend/internal/server/passkey"
"github.com/bit-issues/backend/internal/server/projects"
"github.com/bit-issues/backend/internal/server/tasks"
@@ -45,6 +46,7 @@ func Module() fx.Option {
fx.Annotate(passkey.NewHandler, fx.ResultTags(`group:"handlers"`)),
fx.Annotate(projects.NewHandler, fx.ResultTags(`group:"handlers"`)),
fx.Annotate(tasks.NewHandler, fx.ResultTags(`group:"handlers"`)),
+ fx.Annotate(func(h *oauth.Handler) handler.Handler { return h }, fx.ResultTags(`group:"handlers"`)),
fx.Private,
),
@@ -56,12 +58,21 @@ func Module() fx.Option {
fx.Provide(
webhooks.NewHandler,
+ oauth.NewHandler,
fx.Private,
),
fx.Invoke(
fx.Annotate(
- func(handlers []handler.Handler, jwtAuth fiber.Handler, webhookHandler *webhooks.Handler, healthHandler *health.Handler, openapiHandler *openapi.Handler, app *fiber.App) {
+ func(
+ handlers []handler.Handler,
+ jwtAuth fiber.Handler,
+ webhookHandler *webhooks.Handler,
+ oauthHandler *oauth.Handler,
+ healthHandler *health.Handler,
+ openapiHandler *openapi.Handler,
+ app *fiber.App,
+ ) {
// Health endpoint
healthHandler.Register(app)
@@ -74,6 +85,7 @@ func Module() fx.Option {
v1.Use(validation.Middleware)
webhookHandler.Register(v1)
+ oauthHandler.RegisterPublic(v1)
v1.Use(
jwtAuth,
diff --git a/internal/server/oauth/dto.go b/internal/server/oauth/dto.go
new file mode 100644
index 0000000..91ecf43
--- /dev/null
+++ b/internal/server/oauth/dto.go
@@ -0,0 +1,54 @@
+package oauth
+
+import (
+ "strings"
+ "time"
+
+ "github.com/bit-issues/backend/internal/oauth"
+)
+
+// AuthorizeResponse is the JSON body of the authorize endpoint.
+type AuthorizeResponse struct {
+ URL string `json:"url" example:"https://bitbucket.org/site/oauth2/authorize?client_id=...&response_type=code&scope=webhook&state=...&redirect_uri=..."`
+}
+
+// StatusResponse reports the current OAuth connection state. Optional fields
+// are omitted when the connection is absent.
+type StatusResponse struct {
+ Connected bool `json:"connected"`
+ ConnectedAt *string `json:"connected_at,omitempty" example:"2026-08-24T12:00:00Z"`
+ ExpiresAt *string `json:"expires_at,omitempty" example:"2026-08-24T14:00:00Z"`
+ Scopes []string `json:"scopes,omitempty" example:"webhook"`
+}
+
+// NewStatusResponse maps a stored OAuth token to the status DTO.
+func NewStatusResponse(token *oauth.Token) StatusResponse {
+ if token == nil {
+ return StatusResponse{
+ Connected: false,
+ ConnectedAt: nil,
+ ExpiresAt: nil,
+ Scopes: nil,
+ }
+ }
+
+ response := StatusResponse{
+ Connected: true,
+ ConnectedAt: nil,
+ ExpiresAt: nil,
+ Scopes: nil,
+ }
+ if !token.CreatedAt.IsZero() {
+ connectedAt := token.CreatedAt.Format(time.RFC3339)
+ response.ConnectedAt = &connectedAt
+ }
+ if !token.ExpiresAt.IsZero() {
+ expiresAt := token.ExpiresAt.Format(time.RFC3339)
+ response.ExpiresAt = &expiresAt
+ }
+ if scopes := strings.Fields(token.Scopes); len(scopes) > 0 {
+ response.Scopes = scopes
+ }
+
+ return response
+}
diff --git a/internal/server/oauth/handler.go b/internal/server/oauth/handler.go
new file mode 100644
index 0000000..69fd5a3
--- /dev/null
+++ b/internal/server/oauth/handler.go
@@ -0,0 +1,198 @@
+package oauth
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/bit-issues/backend/internal/oauth"
+ "github.com/bit-issues/backend/internal/server/middlewares/jwtauth"
+ "github.com/bit-issues/backend/internal/users"
+ "github.com/gofiber/fiber/v2"
+ "go.uber.org/zap"
+)
+
+// Handler serves the Bitbucket OAuth connection lifecycle endpoints.
+type Handler struct {
+ oauthSvc *oauth.Service
+ logger *zap.Logger
+}
+
+// NewHandler creates the OAuth HTTP handler.
+func NewHandler(
+ oauthSvc *oauth.Service,
+ logger *zap.Logger,
+) *Handler {
+ return &Handler{
+ oauthSvc: oauthSvc,
+ logger: logger,
+ }
+}
+
+func (h *Handler) RegisterPublic(r fiber.Router) {
+ r.Get("/oauth/bitbucket/callback", h.errorHandler, h.callback)
+}
+
+func (h *Handler) Register(r fiber.Router) {
+ group := r.Group("/oauth/bitbucket", jwtauth.WithRole(users.RoleAdmin), h.errorHandler)
+ group.Get("/authorize", h.authorize)
+ group.Get("/status", h.status)
+ group.Post("/disconnect", h.disconnect)
+}
+
+// @Summary Bitbucket OAuth callback
+// @Description Public callback that exchanges the authorization code with Bitbucket, stores the tokens, and redirects to the admin settings page.
+// @Tags OAuth
+// @Param code query string false "Authorization code"
+// @Param state query string false "State"
+// @Param error query string false "Bitbucket OAuth error code"
+// @Success 302
+// @Router /oauth/bitbucket/callback [get]
+//
+// callback completes the Bitbucket OAuth connection flow (public). All
+// outcomes are browser redirects; tokens are never included in them.
+func (h *Handler) callback(c *fiber.Ctx) error {
+ code := c.Query("code")
+ state := c.Query("state")
+
+ // Bitbucket declined the authorization (e.g. the admin denied consent).
+ if denied := c.Query("error"); denied != "" {
+ return h.redirect(c, "?oauth=error&reason=access_denied")
+ }
+
+ if state == "" || code == "" {
+ return h.redirect(c, "?oauth=error&reason=missing_params")
+ }
+
+ if exchErr := h.oauthSvc.Exchange(c.Context(), state, code); exchErr != nil {
+ h.logger.Error("oauth callback token exchange failed", zap.Error(exchErr))
+ if errors.Is(exchErr, oauth.ErrStateNotFound) {
+ return h.redirect(c, "?oauth=error&reason=invalid_state")
+ }
+ return h.redirect(c, "?oauth=error&reason=exchange_failed")
+ }
+
+ return h.redirect(c, "?oauth=success")
+}
+
+// @Summary Start Bitbucket OAuth connection
+// @Description Generates a CSRF state and returns the Bitbucket authorization URL. Only administrators can perform this action.
+// @Tags OAuth
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Success 200 {object} AuthorizeResponse
+// @Failure 401 {object} fiberfx.ErrorResponse
+// @Failure 403 {object} fiberfx.ErrorResponse
+// @Router /oauth/bitbucket/authorize [get]
+//
+// authorize starts the Bitbucket OAuth connection flow (admin only).
+func (h *Handler) authorize(c *fiber.Ctx) error {
+ user, ok := jwtauth.GetUser(c)
+ if !ok {
+ return fiber.ErrUnauthorized
+ }
+
+ url, err := h.oauthSvc.AuthorizeURL(c.Context(), user.ID)
+ if err != nil {
+ return fmt.Errorf("failed to build authorize url: %w", err)
+ }
+
+ c.Set(fiber.HeaderCacheControl, "no-cache")
+ return c.JSON(AuthorizeResponse{URL: url})
+}
+
+// @Summary Bitbucket OAuth connection status
+// @Description Returns whether a Bitbucket OAuth credential is stored. Only administrators can perform this action.
+// @Tags OAuth
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Success 200 {object} StatusResponse
+// @Failure 401 {object} fiberfx.ErrorResponse
+// @Failure 403 {object} fiberfx.ErrorResponse
+// @Router /oauth/bitbucket/status [get]
+//
+// status reports the stored Bitbucket OAuth connection (admin only).
+func (h *Handler) status(c *fiber.Ctx) error {
+ user, ok := jwtauth.GetUser(c)
+ if !ok {
+ return fiber.ErrUnauthorized
+ }
+
+ token, err := h.oauthSvc.GetToken(c.Context(), user.ID)
+ if err != nil {
+ // A missing token, or a token that could not be issued/refreshed
+ // (e.g. Bitbucket rejected the grant), means there is no usable
+ // connection: report disconnected rather than erroring.
+ if errors.Is(err, oauth.ErrNotFound) || errors.Is(err, oauth.ErrTokenIssueFailed) {
+ return c.JSON(StatusResponse{
+ Connected: false,
+ ConnectedAt: nil,
+ ExpiresAt: nil,
+ Scopes: nil,
+ })
+ }
+ return fmt.Errorf("failed to get oauth status: %w", err)
+ }
+
+ return c.JSON(NewStatusResponse(token))
+}
+
+// @Summary Disconnect Bitbucket OAuth
+// @Description Removes the stored Bitbucket OAuth credential. Remote repository webhooks are NOT removed; they stop working after the token expires. Only administrators can perform this action.
+// @Tags OAuth
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Success 204
+// @Failure 401 {object} fiberfx.ErrorResponse
+// @Failure 403 {object} fiberfx.ErrorResponse
+// @Router /oauth/bitbucket/disconnect [post]
+//
+// disconnect removes the stored OAuth credential (admin only).
+func (h *Handler) disconnect(c *fiber.Ctx) error {
+ user, ok := jwtauth.GetUser(c)
+ if !ok {
+ return fiber.ErrUnauthorized
+ }
+
+ if err := h.oauthSvc.DeleteToken(c.Context(), user.ID); err != nil {
+ return fmt.Errorf("failed to disconnect bitbucket oauth: %w", err)
+ }
+
+ h.logger.Info("bitbucket oauth disconnected")
+
+ return c.SendStatus(fiber.StatusNoContent)
+}
+
+// redirect sends the browser to the admin settings page with the given query
+// string. The SPA uses a hash router, so the admin route lives in the
+// fragment and the oauth result is carried as a hash query string
+// (e.g. "/#/admin?oauth=success"). Errors are wrapped per the repository error
+// policy.
+func (h *Handler) redirect(c *fiber.Ctx, query string) error {
+ target := "/#/admin" + query
+ if err := c.Redirect(target); err != nil {
+ return fmt.Errorf("failed to redirect to admin settings: %w", err)
+ }
+
+ return nil
+}
+
+func (h *Handler) errorHandler(c *fiber.Ctx) error {
+ err := c.Next()
+ if err == nil {
+ return nil
+ }
+
+ switch {
+ case errors.Is(err, oauth.ErrNotFound),
+ errors.Is(err, oauth.ErrTokenIssueFailed),
+ errors.Is(err, oauth.ErrStateNotFound):
+ return fiber.NewError(fiber.StatusUnauthorized, err.Error())
+ case errors.Is(err, oauth.ErrDisabled):
+ return fiber.NewError(fiber.StatusNotImplemented, err.Error())
+ default:
+ return err //nolint:wrapcheck // err is already wrapped
+ }
+}
diff --git a/requests.http b/requests.http
index f4c86ab..a06ba4c 100644
--- a/requests.http
+++ b/requests.http
@@ -27,7 +27,7 @@ Content-Type: application/json
# Uses refresh_token from the login response
# @name adminRefresh
@adminAccessToken={{adminRefresh.response.body.$.access_token}}
-@adminRefreshToken={{adminRefresh.response.body.$.refresh_token}}
+@adminRefreshToken={{adminLogin.response.body.$.refresh_token}}
POST {{baseURL}}/auth/refresh
Content-Type: application/json
@@ -478,3 +478,18 @@ X-Hub-Signature-256: sha256=c8d3e58153ab0c75626df9ef241dd1240d748a922592fb05e179
},
"actor": {"nickname": "dev1"}
}
+
+###
+GET {{baseURL}}/oauth/bitbucket/authorize HTTP/1.1
+Authorization: Bearer {{adminAccessToken}}
+
+###
+GET {{baseURL}}/oauth/bitbucket/callback?
+
+###
+GET {{baseURL}}/oauth/bitbucket/status HTTP/1.1
+Authorization: Bearer {{adminAccessToken}}
+
+###
+POST {{baseURL}}/oauth/bitbucket/disconnect HTTP/1.1
+Authorization: Bearer {{adminAccessToken}}
\ No newline at end of file