Skip to content

Commit bfe0874

Browse files
fankclaude
andauthored
feat: add OAuth 2.0 (3LO) authentication support (#380)
* feat: add OAuth 2.0 (3LO) authentication support Implements OAuth 2.0 three-legged authentication for Atlassian Cloud services as requested in issue #251. Key features: - OAuth2Service interface and implementation for handling OAuth flows - Support for authorization URL generation, token exchange, and refresh - Integration with existing client through functional options pattern - Accessible resources API support for multi-site authentication - Comprehensive tests and example implementation The implementation maintains backward compatibility by: - Not modifying the existing Authentication interface - Using functional options (WithOAuth) for client configuration - OAuth tokens work seamlessly with existing bearer token support 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve golangci-lint unused function warning Convert callbackHandler to ExampleCallbackHandler to fix unused function warning * feat: add automatic OAuth token renewal support - Add WithOAuthAutoRenew option for automatic token refresh - Implement token caching with ReuseTokenSource (5-minute expiry buffer) - Create OAuth2 transport wrapper for automatic authorization headers - Thread-safe token renewal with proper mutex handling - Update refresh tokens when new ones are provided - Add comprehensive tests for token renewal functionality - Add example demonstrating auto-renewal usage - Update README with auto-renewal documentation * refactor: improve OAuth API with better separation of concerns - Separate OAuth configuration from token management - WithOAuth(config) now only sets up OAuth service - WithAutoRenewalToken(token) enables auto-renewal (requires OAuth) - WithOAuthWithAutoRenewal(config, token) as convenience method - Fix unexported field issue in Transport struct - Convert example main functions to proper Example functions - Add comprehensive example showing new API usage - Update documentation to reflect cleaner API design The new API provides better separation of concerns: - OAuth service configuration is independent of token management - Auto-renewal is an explicit opt-in feature - Clearer error messages when dependencies aren't met * docs: fix markdown formatting in OAuth sections - Fix line length issues by breaking long lines - Clean up code examples for better readability - Improve code formatting and indentation - Add proper spacing in multi-site example - Use consistent variable naming (ctx instead of context.Background()) * feat: add HTTP server examples for OAuth 2.0 callback handling Add two comprehensive examples showing OAuth integration with HTTP servers: 1. jira_oauth2_simple_server_example.go: - Minimal HTTP server setup for OAuth callback - Basic callback handling with CSRF protection - Simple HTML interface for OAuth flow - Demonstrates auto-renewal client creation 2. jira_oauth2_http_server_example.go: - Full-featured OAuth server with HTML interface - Complete callback handling with error pages - Site discovery and resource listing - Proper server lifecycle management - CSRF protection with random state generation - Comprehensive error handling and user feedback Both examples demonstrate: - Proper OAuth callback URL handling - State parameter verification for security - Token exchange and auto-renewal setup - Integration with accessible resources API - Clean separation between OAuth flow and API usage Update README with links to new HTTP server examples. * feat: extend OAuth 2.0 support to all API clients - Add OAuth support to all 9 API clients (Jira v2, Agile, SM, Confluence v1/v2, Admin, Assets, Bitbucket) - Implement consistent ClientOption pattern with WithOAuth(), WithAutoRenewalToken(), WithOAuthWithAutoRenewal() - Maintain backward compatibility - existing code continues to work - Add multi-service OAuth example demonstrating usage across services - Update README with comprehensive OAuth documentation for all clients - Enable bearer token authentication for Assets client 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve build and lint issues in multi-service OAuth example - Fix Confluence v2 API method calls: use Space.Bulk() instead of Space.Gets() - Fix Admin Organization API calls: use correct cursor parameter and access org.Attributes.Name - Rename main() function to ExampleMain() to avoid conflicts with other examples - Ensure all API calls use correct method signatures and data structures 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add external token storage support for OAuth2 Implement TokenStore and TokenCallback interfaces to enable external storage of OAuth2 tokens (Redis, files, databases, etc.) and token refresh callbacks. Key changes: - Add TokenStore interface for persistent token storage - Add TokenCallback interface for token refresh notifications - Enhance ReuseTokenSource and RefreshTokenSource with storage support - Add WithTokenStore and WithTokenCallback client options - Maintain full backward compatibility Examples added: - Redis-based token storage implementation - File-based token storage implementation - In-memory storage demonstration This allows distributed applications to share tokens across instances and enables monitoring/auditing of token refresh events. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: simplify OAuth2 examples and fix build issues - Add build tags to prevent multiple main functions - Reduce OAuth2 examples from 7 to 5 focused examples - Remove redundant examples (simple server, new flow) - Rename examples for clarity and consistency - Fix compilation issues with examples - Add overview comment explaining example structure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: implement token storage support for all API clients Add external token storage support to all 9 API clients without code duplication: - Create shared HTTPWrapper and helper functions in oauth2 package - Add WithTokenStore and WithTokenCallback options to all clients - Update WithAutoRenewalToken to use shared token source setup logic - Maintain backward compatibility with existing OAuth implementation This allows all clients to use external storage (Redis, files, databases) for tokens in distributed applications while keeping the code DRY. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: run go mod tidy Clean up module dependencies and fix line endings 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: make refresh token storage synchronous and handle errors properly Critical fix for token storage reliability: - Make SetRefreshToken synchronous since refresh tokens are irreplaceable - Return errors from Token() if refresh token storage fails - Keep SetToken async for performance (access tokens can be refreshed) - Add comprehensive documentation explaining the design rationale - Add tests to verify refresh token storage errors are propagated This prevents silent failures where refresh tokens could be lost, forcing users to re-authenticate. The async behavior for access tokens is retained for performance since they can be regenerated. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 19d1551 commit bfe0874

26 files changed

Lines changed: 4496 additions & 300 deletions

README.md

Lines changed: 463 additions & 263 deletions
Large diffs are not rendered by default.

admin/api_client_impl.go

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,112 @@ import (
1111

1212
"github.com/ctreminiom/go-atlassian/v2/admin/internal"
1313
model "github.com/ctreminiom/go-atlassian/v2/pkg/infra/models"
14+
"github.com/ctreminiom/go-atlassian/v2/pkg/infra/oauth2"
1415
"github.com/ctreminiom/go-atlassian/v2/service/common"
1516
)
1617

1718
const defaultAPIEndpoint = "https://api.atlassian.com/"
1819

20+
// ClientOption is a function that configures a Client
21+
type ClientOption func(*Client) error
22+
23+
// WithOAuth configures the client with OAuth 2.0 support
24+
func WithOAuth(config *common.OAuth2Config) ClientOption {
25+
return func(c *Client) error {
26+
if config == nil {
27+
return fmt.Errorf("oauth config cannot be nil")
28+
}
29+
30+
oauthService, err := oauth2.NewOAuth2Service(c.HTTP, config)
31+
if err != nil {
32+
return fmt.Errorf("failed to create OAuth service: %w", err)
33+
}
34+
35+
c.OAuth = oauthService
36+
return nil
37+
}
38+
}
39+
40+
// WithAutoRenewalToken enables automatic OAuth token renewal with the provided token.
41+
// This option requires WithOAuth to be configured first or used together.
42+
func WithAutoRenewalToken(token *common.OAuth2Token) ClientOption {
43+
return func(c *Client) error {
44+
if token == nil {
45+
return fmt.Errorf("token cannot be nil for auto-renewal")
46+
}
47+
48+
if c.OAuth == nil {
49+
return fmt.Errorf("OAuth must be configured before enabling auto-renewal (use WithOAuth first)")
50+
}
51+
52+
// Create token sources with storage support if configured
53+
_, reuseSource, err := oauth2.SetupTokenSourcesWithStorage(
54+
context.Background(),
55+
token,
56+
c.OAuth,
57+
c.HTTP,
58+
)
59+
if err != nil {
60+
return fmt.Errorf("failed to setup token sources: %w", err)
61+
}
62+
63+
// Extract base transport and restore original HTTP client if wrapped
64+
base := oauth2.ExtractBaseTransport(c.HTTP)
65+
if wrapper, ok := oauth2.ExtractWrapper(c.HTTP); ok {
66+
c.HTTP = wrapper.OriginalClient
67+
}
68+
69+
// Create OAuth transport
70+
c.HTTP = oauth2.CreateOAuthTransport(reuseSource, base, c.Auth)
71+
72+
// Set initial token
73+
c.Auth.SetBearerToken(token.AccessToken)
74+
75+
return nil
76+
}
77+
}
78+
79+
// WithOAuthWithAutoRenewal is a convenience option that combines WithOAuth and WithAutoRenewalToken.
80+
// It configures OAuth support and enables automatic token renewal in one step.
81+
func WithOAuthWithAutoRenewal(config *common.OAuth2Config, token *common.OAuth2Token) ClientOption {
82+
return func(c *Client) error {
83+
// First configure OAuth
84+
if err := WithOAuth(config)(c); err != nil {
85+
return err
86+
}
87+
88+
// Then enable auto-renewal
89+
return WithAutoRenewalToken(token)(c)
90+
}
91+
}
92+
93+
// WithTokenStore configures the client to use external token storage
94+
func WithTokenStore(store oauth2.TokenStore) ClientOption {
95+
return func(c *Client) error {
96+
if store == nil {
97+
return fmt.Errorf("token store cannot be nil")
98+
}
99+
100+
c.HTTP = oauth2.WrapHTTPClient(c.HTTP).WithStore(store)
101+
return nil
102+
}
103+
}
104+
105+
// WithTokenCallback configures the client to use a callback for token refresh events
106+
func WithTokenCallback(callback oauth2.TokenCallback) ClientOption {
107+
return func(c *Client) error {
108+
if callback == nil {
109+
return fmt.Errorf("token callback cannot be nil")
110+
}
111+
112+
c.HTTP = oauth2.WrapHTTPClient(c.HTTP).WithCallback(callback)
113+
return nil
114+
}
115+
}
116+
19117
// New creates a new instance of Client.
20-
// It takes a common.HTTPClient as input and returns a pointer to Client and an error.
21-
func New(httpClient common.HTTPClient) (*Client, error) {
118+
// It takes a common.HTTPClient and optional configuration options as input and returns a pointer to Client and an error.
119+
func New(httpClient common.HTTPClient, options ...ClientOption) (*Client, error) {
22120

23121
// If no HTTP client is provided, use the default HTTP client.
24122
if httpClient == nil {
@@ -56,6 +154,13 @@ func New(httpClient common.HTTPClient) (*Client, error) {
56154
// Initialize the User service with a user token service.
57155
client.User = internal.NewUserService(client, internal.NewUserTokenService(client))
58156

157+
// Apply client options
158+
for _, option := range options {
159+
if err := option(client); err != nil {
160+
return nil, err
161+
}
162+
}
163+
59164
return client, nil
60165
}
61166

@@ -67,6 +172,8 @@ type Client struct {
67172
Site *url.URL
68173
// Auth is the authentication service.
69174
Auth common.Authentication
175+
// OAuth is the OAuth 2.0 service.
176+
OAuth common.OAuth2Service
70177
// Organization is the service for organization-related operations.
71178
Organization *internal.OrganizationService
72179
// User is the service for user-related operations.

assets/api_client_impl.go

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,112 @@ import (
1111

1212
"github.com/ctreminiom/go-atlassian/v2/assets/internal"
1313
model "github.com/ctreminiom/go-atlassian/v2/pkg/infra/models"
14+
"github.com/ctreminiom/go-atlassian/v2/pkg/infra/oauth2"
1415
"github.com/ctreminiom/go-atlassian/v2/service/common"
1516
)
1617

1718
const DefaultAssetsSite = "https://api.atlassian.com/"
1819

20+
// ClientOption is a function that configures a Client
21+
type ClientOption func(*Client) error
22+
23+
// WithOAuth configures the client with OAuth 2.0 support
24+
func WithOAuth(config *common.OAuth2Config) ClientOption {
25+
return func(c *Client) error {
26+
if config == nil {
27+
return fmt.Errorf("oauth config cannot be nil")
28+
}
29+
30+
oauthService, err := oauth2.NewOAuth2Service(c.HTTP, config)
31+
if err != nil {
32+
return fmt.Errorf("failed to create OAuth service: %w", err)
33+
}
34+
35+
c.OAuth = oauthService
36+
return nil
37+
}
38+
}
39+
40+
// WithAutoRenewalToken enables automatic OAuth token renewal with the provided token.
41+
// This option requires WithOAuth to be configured first or used together.
42+
func WithAutoRenewalToken(token *common.OAuth2Token) ClientOption {
43+
return func(c *Client) error {
44+
if token == nil {
45+
return fmt.Errorf("token cannot be nil for auto-renewal")
46+
}
47+
48+
if c.OAuth == nil {
49+
return fmt.Errorf("OAuth must be configured before enabling auto-renewal (use WithOAuth first)")
50+
}
51+
52+
// Create token sources with storage support if configured
53+
_, reuseSource, err := oauth2.SetupTokenSourcesWithStorage(
54+
context.Background(),
55+
token,
56+
c.OAuth,
57+
c.HTTP,
58+
)
59+
if err != nil {
60+
return fmt.Errorf("failed to setup token sources: %w", err)
61+
}
62+
63+
// Extract base transport and restore original HTTP client if wrapped
64+
base := oauth2.ExtractBaseTransport(c.HTTP)
65+
if wrapper, ok := oauth2.ExtractWrapper(c.HTTP); ok {
66+
c.HTTP = wrapper.OriginalClient
67+
}
68+
69+
// Create OAuth transport
70+
c.HTTP = oauth2.CreateOAuthTransport(reuseSource, base, c.Auth)
71+
72+
// Set initial token
73+
c.Auth.SetBearerToken(token.AccessToken)
74+
75+
return nil
76+
}
77+
}
78+
79+
// WithOAuthWithAutoRenewal is a convenience option that combines WithOAuth and WithAutoRenewalToken.
80+
// It configures OAuth support and enables automatic token renewal in one step.
81+
func WithOAuthWithAutoRenewal(config *common.OAuth2Config, token *common.OAuth2Token) ClientOption {
82+
return func(c *Client) error {
83+
// First configure OAuth
84+
if err := WithOAuth(config)(c); err != nil {
85+
return err
86+
}
87+
88+
// Then enable auto-renewal
89+
return WithAutoRenewalToken(token)(c)
90+
}
91+
}
92+
93+
// WithTokenStore configures the client to use external token storage
94+
func WithTokenStore(store oauth2.TokenStore) ClientOption {
95+
return func(c *Client) error {
96+
if store == nil {
97+
return fmt.Errorf("token store cannot be nil")
98+
}
99+
100+
c.HTTP = oauth2.WrapHTTPClient(c.HTTP).WithStore(store)
101+
return nil
102+
}
103+
}
104+
105+
// WithTokenCallback configures the client to use a callback for token refresh events
106+
func WithTokenCallback(callback oauth2.TokenCallback) ClientOption {
107+
return func(c *Client) error {
108+
if callback == nil {
109+
return fmt.Errorf("token callback cannot be nil")
110+
}
111+
112+
c.HTTP = oauth2.WrapHTTPClient(c.HTTP).WithCallback(callback)
113+
return nil
114+
}
115+
}
116+
19117
// New creates a new instance of Client.
20118
// It takes a common.HTTPClient and a site URL as inputs and returns a pointer to Client and an error.
21-
func New(httpClient common.HTTPClient, site string) (*Client, error) {
119+
func New(httpClient common.HTTPClient, site string, options ...ClientOption) (*Client, error) {
22120

23121
// If no HTTP client is provided, use the default HTTP client.
24122
if httpClient == nil {
@@ -53,6 +151,13 @@ func New(httpClient common.HTTPClient, site string) (*Client, error) {
53151
client.ObjectType = internal.NewObjectTypeService(client)
54152
client.ObjectTypeAttribute = internal.NewObjectTypeAttributeService(client)
55153

154+
// Apply client options
155+
for _, option := range options {
156+
if err := option(client); err != nil {
157+
return nil, err
158+
}
159+
}
160+
56161
return client, nil
57162
}
58163

@@ -64,6 +169,8 @@ type Client struct {
64169
Site *url.URL
65170
// Auth is the authentication service.
66171
Auth common.Authentication
172+
// OAuth is the OAuth 2.0 service.
173+
OAuth common.OAuth2Service
67174
// AQL is the service for AQL-related operations.
68175
AQL *internal.AQLService
69176
// Icon is the service for icon-related operations.
@@ -120,6 +227,11 @@ func (c *Client) NewRequest(ctx context.Context, method, urlStr, contentType str
120227
req.SetBasicAuth(c.Auth.GetBasicAuth())
121228
}
122229

230+
// Add the Bearer token if available and not using Basic Auth.
231+
if c.Auth.GetBearerToken() != "" && !c.Auth.HasBasicAuth() {
232+
req.Header.Add("Authorization", fmt.Sprintf("Bearer %v", c.Auth.GetBearerToken()))
233+
}
234+
123235
// Set the User-Agent header if available.
124236
if c.Auth.HasUserAgent() {
125237
req.Header.Set("User-Agent", c.Auth.GetUserAgent())

0 commit comments

Comments
 (0)