or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

auth-handlers.mdclient-credentials.mdcore-oauth2.mdendpoints.mdgoogle-auth.mdgoogle-downscope.mdgoogle-external-account.mdindex.mdjira-oauth.mdjwt-jws.md
tile.json

jira-oauth.mddocs/

JIRA OAuth

Specialized OAuth2 support for JIRA and Confluence with JWT bearer token authentication using HMAC-SHA256 signing.

Package

import "golang.org/x/oauth2/jira"

Overview

The jira package provides JWT-based OAuth2 authentication specifically designed for Atlassian JIRA and Confluence. It implements the JWT Bearer Token Authorization Grant Type with HMAC-SHA256 signing.

Documentation: https://developer.atlassian.com/cloud/jira/software/oauth-2-jwt-bearer-token-authorization-grant-type/

Configuration

type Config struct {
	// BaseURL for your app
	BaseURL string

	// Subject is the userkey as defined by Atlassian
	// Different than username (fetch from: /rest/api/2/user?username=alex)
	Subject string

	// Embedded oauth2.Config
	oauth2.Config
}

Creating Clients

func (c *Config) Client(ctx context.Context) *http.Client

Returns an HTTP client that automatically handles JWT token generation and adds Authorization headers. The returned client and its Transport should not be modified.

Token Source

func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource

Returns a JWT TokenSource using the configuration and HTTP client from the context. Most users should use Client instead.

Claim Set

type ClaimSet struct {
	// Issuer in format: urn:atlassian:connect:clientid:{ClientID}
	Issuer string `json:"iss"`

	// Subject in format: urn:atlassian:connect:useraccountid:{Subject}
	Subject string `json:"sub"`

	// InstalledURL is the URL of the installed app
	InstalledURL string `json:"tnt"`

	// AuthURL is the URL of the auth server
	AuthURL string `json:"aud"`

	// ExpiresIn must be no later than 60 seconds in the future
	ExpiresIn int64 `json:"exp"`

	// IssuedAt is the timestamp when the token was issued
	IssuedAt int64 `json:"iat"`
}

Basic Example

package main

import (
	"context"
	"fmt"
	"io"
	"log"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/jira"
)

func main() {
	ctx := context.Background()

	// Configure JIRA OAuth
	config := &jira.Config{
		BaseURL: "https://your-domain.atlassian.net",
		Subject: "user-account-id", // Get from /rest/api/2/user?username=alex
		Config: oauth2.Config{
			ClientID:     "your-client-id",
			ClientSecret: "your-client-secret", // Shared secret
			Endpoint: oauth2.Endpoint{
				AuthURL:  "https://your-domain.atlassian.net",
				TokenURL: "https://your-domain.atlassian.net/rest/oauth2/token",
			},
			Scopes: []string{"read:jira-user", "write:jira-work"},
		},
	}

	// Create authenticated client
	client := config.Client(ctx)

	// Use client to access JIRA API
	resp, err := client.Get("https://your-domain.atlassian.net/rest/api/2/myself")
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	fmt.Printf("User info: %s\n", body)
}

Getting User Account ID

The Subject field requires the user account ID, not the username:

// Make a request to get the user account ID
resp, err := http.Get("https://your-domain.atlassian.net/rest/api/2/user?username=alex")
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close()

var user struct {
	AccountID string `json:"accountId"`
}
json.NewDecoder(resp.Body).Decode(&user)

// Use this accountId as the Subject
config.Subject = user.AccountID

Common JIRA Scopes

Scopes are automatically converted to uppercase and joined with +:

config.Scopes = []string{
	"read:jira-user",
	"read:jira-work",
	"write:jira-work",
	"manage:jira-project",
	"manage:jira-configuration",
}

Common scopes:

  • read:jira-user - Read user information
  • read:jira-work - Read issues, projects, etc.
  • write:jira-work - Create and update issues, comments
  • manage:jira-project - Manage projects
  • manage:jira-configuration - Manage Jira configuration
  • manage:jira-webhook - Manage webhooks

Confluence Integration

The same package works for Confluence:

config := &jira.Config{
	BaseURL: "https://your-domain.atlassian.net/wiki",
	Subject: "user-account-id",
	Config: oauth2.Config{
		ClientID:     "your-client-id",
		ClientSecret: "your-client-secret",
		Endpoint: oauth2.Endpoint{
			AuthURL:  "https://your-domain.atlassian.net/wiki",
			TokenURL: "https://your-domain.atlassian.net/wiki/rest/oauth2/token",
		},
		Scopes: []string{"read:confluence-content.all", "write:confluence-content"},
	},
}

client := config.Client(ctx)
resp, _ := client.Get("https://your-domain.atlassian.net/wiki/rest/api/content")

Complete Example with Error Handling

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/jira"
)

type JiraIssue struct {
	Key    string `json:"key"`
	Fields struct {
		Summary     string `json:"summary"`
		Description string `json:"description"`
	} `json:"fields"`
}

func main() {
	ctx := context.Background()

	config := &jira.Config{
		BaseURL: "https://mycompany.atlassian.net",
		Subject: "557058:12345678-1234-1234-1234-123456789abc",
		Config: oauth2.Config{
			ClientID:     "oauth-client-id",
			ClientSecret: "shared-secret-key",
			Endpoint: oauth2.Endpoint{
				AuthURL:  "https://mycompany.atlassian.net",
				TokenURL: "https://mycompany.atlassian.net/rest/oauth2/token",
			},
			Scopes: []string{
				"read:jira-work",
				"write:jira-work",
			},
		},
	}

	// Get token source
	ts := config.TokenSource(ctx)

	// Get a token to verify configuration
	token, err := ts.Token()
	if err != nil {
		log.Fatalf("Failed to get token: %v", err)
	}
	fmt.Printf("Got access token (expires: %v)\n", token.Expiry)

	// Create client
	client := config.Client(ctx)

	// Fetch an issue
	resp, err := client.Get("https://mycompany.atlassian.net/rest/api/2/issue/PROJ-123")
	if err != nil {
		log.Fatalf("Failed to fetch issue: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		log.Fatalf("API error: %s - %s", resp.Status, body)
	}

	var issue JiraIssue
	if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
		log.Fatalf("Failed to decode response: %v", err)
	}

	fmt.Printf("Issue: %s\n", issue.Key)
	fmt.Printf("Summary: %s\n", issue.Fields.Summary)

	// Create a new issue
	newIssue := map[string]any{
		"fields": map[string]any{
			"project": map[string]string{
				"key": "PROJ",
			},
			"summary":     "New issue created via API",
			"description": "This issue was created using the JIRA OAuth2 package",
			"issuetype": map[string]string{
				"name": "Task",
			},
		},
	}

	issueJSON, _ := json.Marshal(newIssue)
	createResp, err := client.Post(
		"https://mycompany.atlassian.net/rest/api/2/issue",
		"application/json",
		bytes.NewReader(issueJSON),
	)
	if err != nil {
		log.Fatalf("Failed to create issue: %v", err)
	}
	defer createResp.Body.Close()

	if createResp.StatusCode != http.StatusCreated {
		body, _ := io.ReadAll(createResp.Body)
		log.Fatalf("Failed to create issue: %s - %s", createResp.Status, body)
	}

	var created struct {
		Key string `json:"key"`
	}
	json.NewDecoder(createResp.Body).Decode(&created)
	fmt.Printf("Created issue: %s\n", created.Key)
}

Token Characteristics

  • Token Type: JWT Bearer Token
  • Signing Algorithm: HMAC-SHA256
  • Token Lifetime: 59 seconds (hardcoded for safety)
  • Grant Type: urn:ietf:params:oauth:grant-type:jwt-bearer
  • Auto-refresh: Handled automatically by the client

Security Considerations

  1. Protect Client Secret: The shared secret is used for HMAC signing - never expose it
  2. Short Token Lifetime: Tokens expire in 59 seconds, minimizing exposure
  3. HTTPS Only: Always use HTTPS endpoints
  4. Scope Limitation: Request only necessary scopes
  5. User Account ID: Validate user account IDs before use
  6. Token Reuse: The package automatically handles token refresh

Differences from Standard OAuth2

FeatureJIRA OAuthStandard OAuth2
SigningHMAC-SHA256RSA (typically)
Token lifetime59 secondsConfigurable
Grant typeJWT BearerVarious
User identifierAccount IDEmail/username
Scope formatUppercase + joinedSpace-separated

Troubleshooting

Invalid Subject Error

Ensure you're using the account ID, not username:

// Wrong
config.Subject = "alex"

// Correct
config.Subject = "557058:12345678-1234-1234-1234-123456789abc"

Token Expired Immediately

The 59-second limit is intentional. The client automatically refreshes:

// Don't manually refresh - the client handles it
client := config.Client(ctx)
// Each request automatically gets a fresh token if needed

Scope Format Issues

Scopes are automatically converted to uppercase:

// Both work the same
config.Scopes = []string{"read:jira-work"}
config.Scopes = []string{"READ:JIRA-WORK"}

API Resources

Common JIRA REST API endpoints:

// User info
GET /rest/api/2/myself

// Get issue
GET /rest/api/2/issue/{issueKey}

// Search issues
POST /rest/api/2/search
Body: {"jql": "project = PROJ"}

// Create issue
POST /rest/api/2/issue
Body: {"fields": {...}}

// Update issue
PUT /rest/api/2/issue/{issueKey}

// Add comment
POST /rest/api/2/issue/{issueKey}/comment
Body: {"body": "Comment text"}

References