From dca2f45676d3cf89d014c9678631f56f5bb71db5 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Wed, 26 Aug 2026 15:01:45 +0700 Subject: [PATCH] [oauth] add bitbucket connection --- .env.example | 22 ++ bitbucket.http | 10 + frontend/src/lib/api/oauth.ts | 20 ++ .../lib/components/BitbucketOAuthCard.svelte | 182 ++++++++++++++++ frontend/src/lib/components/Sidebar.svelte | 5 +- frontend/src/lib/pages/admin.svelte | 46 +++- frontend/src/lib/types/api.ts | 11 + go.mod | 2 +- internal/commands/serve/serve.go | 8 +- internal/config/config.go | 10 + internal/config/module.go | 21 +- .../20260825050007_oauth_tokens.sql | 21 ++ internal/oauth/config.go | 10 + internal/oauth/consts.go | 15 ++ internal/oauth/domain.go | 15 ++ internal/oauth/dto.go | 9 + internal/oauth/errors.go | 16 ++ internal/oauth/export_test.go | 7 + internal/oauth/models.go | 49 +++++ internal/oauth/module.go | 23 ++ internal/oauth/repository.go | 78 +++++++ internal/oauth/service.go | 197 ++++++++++++++++++ internal/oauth/states.go | 75 +++++++ internal/oauth/states_test.go | 24 +++ internal/server/docs/docs.go | 185 ++++++++++++++++ internal/server/module.go | 14 +- internal/server/oauth/dto.go | 54 +++++ internal/server/oauth/handler.go | 192 +++++++++++++++++ requests.http | 17 +- 29 files changed, 1316 insertions(+), 22 deletions(-) create mode 100644 bitbucket.http create mode 100644 frontend/src/lib/api/oauth.ts create mode 100644 frontend/src/lib/components/BitbucketOAuthCard.svelte create mode 100644 internal/db/migrations/20260825050007_oauth_tokens.sql create mode 100644 internal/oauth/config.go create mode 100644 internal/oauth/consts.go create mode 100644 internal/oauth/domain.go create mode 100644 internal/oauth/dto.go create mode 100644 internal/oauth/errors.go create mode 100644 internal/oauth/export_test.go create mode 100644 internal/oauth/models.go create mode 100644 internal/oauth/module.go create mode 100644 internal/oauth/repository.go create mode 100644 internal/oauth/service.go create mode 100644 internal/oauth/states.go create mode 100644 internal/oauth/states_test.go create mode 100644 internal/server/oauth/dto.go create mode 100644 internal/server/oauth/handler.go diff --git a/.env.example b/.env.example index b9abdcb..ee69351 100644 --- a/.env.example +++ b/.env.example @@ -258,3 +258,25 @@ WEBHOOKS__BOT_USER_EMAIL=bot@bitissues.local # Example: {"implements":{"status":"In Progress"},"closes":{"status":"Closed","verb":"Closed"}} # Valid statuses: New, Open, In Progress, Resolved, Closed, Reopened, Invalid, Duplicate, Wontfix, On Hold WEBHOOKS__ACTION_KEYWORDS='{"fixes":{"status":"Resolved","verb":"Resolved"},"fixed":{"status":"Resolved","verb":"Resolved"},"resolves":{"status":"Resolved","verb":"Resolved"},"resolved":{"status":"Resolved","verb":"Resolved"},"closes":{"status":"Closed","verb":"Closed"},"closed":{"status":"Closed","verb":"Closed"},"blocks":{"status":"On Hold","verb":"On Hold"},"blocked":{"status":"On Hold","verb":"On Hold"},"on hold":{"status":"On Hold","verb":"On Hold"}}' + +# ============================================================================= +# BITBUCKET CONFIGURATION +# ============================================================================= + +# Bitbucket OAuth Client ID (optional) +# Purpose: Consumer key of the Bitbucket OAuth consumer app used by the admin +# "Connect with Bitbucket" flow +# Format: String (Bitbucket OAuth consumer key) +# Default: (empty - OAuth connection disabled) +# Setup: Bitbucket workspace settings -> OAuth consumers -> Add consumer. +# Required scope: webhook. See docs/oauth-setup.md for the walkthrough. +OAUTH__CLIENT_ID= + +# Bitbucket OAuth Client Secret (optional) +# Purpose: Consumer secret of the Bitbucket OAuth consumer app; sent only to +# the Bitbucket OAuth token endpoint, never logged +# Format: String (Bitbucket OAuth consumer secret) +# Default: (empty - OAuth connection disabled) +# NOTE: MVP stores the exchanged access and refresh tokens plaintext in the +# oauth_tokens table; encryption at rest is planned post-MVP. +OAUTH__CLIENT_SECRET= diff --git a/bitbucket.http b/bitbucket.http new file mode 100644 index 0000000..7eb02de --- /dev/null +++ b/bitbucket.http @@ -0,0 +1,10 @@ +@client_id={{$dotenv BITBUCKET__CLIENT_ID}} +@client_secret={{$dotenv BITBUCKET__CLIENT_SECRET}} +@code={{$dotenv BITBUCKET__CODE}} + +### +POST https://bitbucket.org/site/oauth2/access_token HTTP/1.1 +Authorization: Basic {{client_id}}:{{client_secret}} +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code&code={{code}} diff --git a/frontend/src/lib/api/oauth.ts b/frontend/src/lib/api/oauth.ts new file mode 100644 index 0000000..80652c9 --- /dev/null +++ b/frontend/src/lib/api/oauth.ts @@ -0,0 +1,20 @@ +import { apiRequest } from './client' +import type { + BitbucketOAuthAuthorizeResponse, + BitbucketOAuthStatus, +} from '$lib/types/api' + +export function getBitbucketOAuthStatus(): Promise { + return apiRequest('GET', '/oauth/bitbucket/status') +} + +export function getBitbucketOAuthAuthorizeUrl(): Promise { + return apiRequest( + 'GET', + '/oauth/bitbucket/authorize', + ) +} + +export function disconnectBitbucketOAuth(): Promise { + return apiRequest('POST', '/oauth/bitbucket/disconnect') +} diff --git a/frontend/src/lib/components/BitbucketOAuthCard.svelte b/frontend/src/lib/components/BitbucketOAuthCard.svelte new file mode 100644 index 0000000..dd2548e --- /dev/null +++ b/frontend/src/lib/components/BitbucketOAuthCard.svelte @@ -0,0 +1,182 @@ + + + + +
+ Bitbucket OAuth + {#if status} + {badgeLabel} + {/if} +
+
+ + {#if loading} +

Loading...

+ {:else if loadError} +

{loadError}

+ {:else if status} +
+

+ {#if connected} + Webhook registration uses the connected Bitbucket app. + {:else} + Connect a Bitbucket app to manage repository webhooks. + {/if} +

+ {#if connected} +
+ + Connected At + + {formatDate(status.connected_at)} +
+
+ + Token Expires At + + {formatDate(status.expires_at)} +
+ {#if status.scopes?.length} +
+ + Scopes + + {status.scopes.join(", ")} +
+ {/if} + {/if} +
+ {/if} +
+ {#if !loading && (status || loadError)} + + {#if loadError && !status} + + {/if} + {#if status} + {#if !connected || expired} + + {/if} + {#if connected} + + {/if} + {/if} + + {/if} +
+ + +

+ Active repository webhooks will stop delivering push events after the + Bitbucket token expires (about 2 hours). No remote webhooks are removed + automatically. +

+ {#snippet footer()} + + + {/snippet} +
diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 8ac7e9b..1c3742a 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -39,6 +39,7 @@ const adminNav = [ { pattern: "/admin/users", label: "Users", icon: UsersIcon }, { pattern: "/admin/projects", label: "Projects", icon: SettingsIcon }, + { pattern: "/admin", label: "Settings", icon: SettingsIcon }, ]; @@ -97,9 +98,7 @@ {#if pattern === "/projects" && recentProjects.length > 0} -
+

Recent

diff --git a/frontend/src/lib/pages/admin.svelte b/frontend/src/lib/pages/admin.svelte index 4746ab3..3c433a5 100644 --- a/frontend/src/lib/pages/admin.svelte +++ b/frontend/src/lib/pages/admin.svelte @@ -1,5 +1,47 @@ + +
+
+

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..aab7a78 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -63,6 +63,11 @@ type webhooksConfig struct { ActionKeywords map[string]webhooks.KeywordEntry `koanf:"action_keywords"` } +type oauthConfig struct { + ClientID string `koanf:"client_id"` + ClientSecret string `koanf:"client_secret"` +} + type Config struct { HTTP http `koanf:"http"` Database databaseConfig `koanf:"database"` @@ -72,6 +77,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 +140,10 @@ func Default() Config { "on hold": {Status: "On Hold", Verb: "On Hold"}, }, }, + OAuth: oauthConfig{ + ClientID: "", + ClientSecret: "", + }, } } diff --git a/internal/config/module.go b/internal/config/module.go index 6c50773..34c5184 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,10 @@ 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, } }, ), diff --git a/internal/db/migrations/20260825050007_oauth_tokens.sql b/internal/db/migrations/20260825050007_oauth_tokens.sql new file mode 100644 index 0000000..9ee6e96 --- /dev/null +++ b/internal/db/migrations/20260825050007_oauth_tokens.sql @@ -0,0 +1,21 @@ +-- +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, + `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..982ebd6 --- /dev/null +++ b/internal/oauth/config.go @@ -0,0 +1,10 @@ +package oauth + +// Config holds OAuth service tunables. Zero values fall back to the defaults +// from DefaultConfig. +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 +} 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/domain.go b/internal/oauth/domain.go new file mode 100644 index 0000000..0bd8654 --- /dev/null +++ b/internal/oauth/domain.go @@ -0,0 +1,15 @@ +package oauth + +import "time" + +// Token is the domain representation of the stored Bitbucket OAuth +// credential. Access and refresh tokens are stored plaintext for the MVP. +type Token struct { + AccessToken string + RefreshToken string + Scopes string + ExpiresAt time.Time + + CreatedAt time.Time + UpdatedAt time.Time +} 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..d89f97a --- /dev/null +++ b/internal/oauth/errors.go @@ -0,0 +1,16 @@ +package oauth + +import "errors" + +var ( + // 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..bcbd57c --- /dev/null +++ b/internal/oauth/models.go @@ -0,0 +1,49 @@ +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"` + Scopes string `bun:"scopes,notnull"` + ExpiresAt time.Time `bun:"expires_at"` +} + +func (t *tokenModel) toDomain() *Token { + return &Token{ + AccessToken: t.AccessToken, + RefreshToken: t.RefreshToken, + ExpiresAt: t.ExpiresAt, + Scopes: t.Scopes, + + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } +} + +func newTokenModel(userID int64, token *Token) *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, + Scopes: token.Scopes, + ExpiresAt: token.ExpiresAt, + } +} diff --git a/internal/oauth/module.go b/internal/oauth/module.go new file mode 100644 index 0000000..170f1b1 --- /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(NewRepository, fx.Private), + fx.Provide( + func(factory cachefx.Factory) (cache.Cache, error) { + return factory.New("oauth") + }, + fx.Private, + ), + fx.Provide(NewService), + ) +} diff --git a/internal/oauth/repository.go b/internal/oauth/repository.go new file mode 100644 index 0000000..874c471 --- /dev/null +++ b/internal/oauth/repository.go @@ -0,0 +1,78 @@ +package oauth + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/uptrace/bun" +) + +// Repository persists the per-user OAuth token row. +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 *Token) 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 currentRefreshToken. 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, currentRefreshToken string, token *Token) (bool, error) { + res, err := r.db.NewUpdate(). + Model((*tokenModel)(nil)). + Set("access_token = ?", token.AccessToken). + Set("refresh_token = ?", token.RefreshToken). + Set("scopes = ?", token.Scopes). + Set("expires_at = ?", token.ExpiresAt). + Set("updated_at = ?", token.UpdatedAt). + Where("user_id = ?", userID). + Where("refresh_token = ?", currentRefreshToken). + 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) (*Token, 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) + } + return model.toDomain(), 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 +} diff --git a/internal/oauth/service.go b/internal/oauth/service.go new file mode 100644 index 0000000..5879cfc --- /dev/null +++ b/internal/oauth/service.go @@ -0,0 +1,197 @@ +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 + states *stateStore + + group singleflight.Group + http *http.Client + + logger *zap.Logger +} + +func NewService( + cfg Config, + tokens *Repository, + backend cache.Cache, + + logger *zap.Logger, +) *Service { + return &Service{ + cfg: cfg, + tokens: tokens, + states: newStateStore(backend), + + group: singleflight.Group{}, + http: http.DefaultClient, + + logger: logger, + } +} + +func (s *Service) AuthorizeURL(ctx context.Context, userID int64) (string, error) { + 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 { + // 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 + } + + return s.tokens.Upsert(ctx, userID, token) +} + +func (s *Service) GetToken(ctx context.Context, userID int64) (*Token, error) { + 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, nil + } + + refreshed, refreshErr := s.refreshLocked(ctx, userID, token) + 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 { + 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) + } + + ok, err := s.tokens.Update(ctx, userID, current.RefreshToken, refreshed) + 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..42b9f63 --- /dev/null +++ b/internal/server/oauth/handler.go @@ -0,0 +1,192 @@ +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.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) + } + 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 errors.Is(err, oauth.ErrNotFound) { + return c.JSON(StatusResponse{ + Connected: false, + ConnectedAt: nil, + ExpiresAt: nil, + Scopes: nil, + }) + } + + if err != 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()) + 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