Skip to content

Commit f58aa6c

Browse files
authored
feat: add auth and additional requested changes #26 (#27)
* feat: add auth and addtional requested changes #26 * updated auth tests
1 parent d639548 commit f58aa6c

File tree

6 files changed

+1379
-9
lines changed

6 files changed

+1379
-9
lines changed

README.md

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Fern JUnit Client
22

3-
A CLI that can read JUnit test reports and send them to a Fern Reporter instance in a format it understands
3+
A CLI that can read JUnit test reports and send them to a Fern Platform instance in a format it understands
44

55
## Introduction
66

7-
If you don't know what Fern is, [check it out here!](https://github.com/guidewire-oss/fern-reporter)
7+
If you don't know what Fern is, [check it out here!](https://github.com/guidewire-oss/fern-platform)
88

99
## Install
1010

@@ -15,15 +15,15 @@ go install github.com/guidewire-oss/fern-junit-client@latest
1515
```
1616

1717

18-
## Registering Application with Fern-Reporter
18+
## Registering Application with Fern Platform
1919
```bash
2020
curl -L -X POST http://localhost:8080/api/project \
2121
-H "Content-Type: application/json" \
2222
-d '{
2323
"name": "First Projects",
2424
"team_name": "my team",
2525
"comment": "This is the test project"
26-
}'
26+
}'
2727
```
2828

2929
Sample Response:
@@ -54,10 +54,84 @@ fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97
5454
fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "tests/*.xml"
5555
```
5656

57+
### Configuration
58+
59+
The fern-junit-client can be configured using environment variables:
60+
61+
#### API Endpoint Configuration
62+
63+
| Environment Variable | Description | Default |
64+
|---------------------|-------------|---------|
65+
| `FERN_API_ENDPOINT_PATH` | Override the API endpoint path | `api/v1/test-runs` |
66+
67+
Example:
68+
```sh
69+
# Use a custom API endpoint path
70+
export FERN_API_ENDPOINT_PATH="api/v2/test-results"
71+
fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "report.xml"
72+
```
73+
74+
### OAuth Authentication
75+
76+
The fern-junit-client supports OAuth 2.0 authentication using the client credentials grant type. This allows the client to authenticate with the Fern backend using OAuth tokens.
77+
78+
#### OAuth Configuration
79+
80+
OAuth authentication is configured via environment variables:
81+
82+
| Environment Variable | Description | Required |
83+
|---------------------|-------------|----------|
84+
| `AUTH_URL` | The OAuth 2.0 token endpoint URL | Yes (to enable OAuth) |
85+
| `FERN_AUTH_CLIENT_ID` | The OAuth client ID | Yes (if OAuth enabled) |
86+
| `FERN_AUTH_CLIENT_SECRET` | The OAuth client secret/password | Yes (if OAuth enabled) |
87+
| `FERN_CLIENT_SCOPE` | Space-separated list of OAuth scopes to request | No (optional) |
88+
89+
#### Behavior
90+
91+
- **OAuth Disabled**: If `AUTH_URL` is not set, the client will operate without authentication (backward compatible behavior).
92+
- **OAuth Enabled**: If `AUTH_URL` is set, the client will:
93+
1. Validate that `FERN_AUTH_CLIENT_ID` and `FERN_AUTH_CLIENT_SECRET` are also provided
94+
2. Request an access token from the OAuth server using client credentials grant
95+
3. Include requested scopes in the token request if `FERN_CLIENT_SCOPE` is set
96+
4. Include the Bearer token in the Authorization header for all API calls to the Fern backend
97+
5. Automatically refresh the token when it expires
98+
99+
#### Examples with OAuth
100+
101+
##### Without OAuth (default)
102+
```sh
103+
fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "report.xml"
104+
```
105+
106+
##### With OAuth
107+
```sh
108+
export AUTH_URL="https://oauth.example.com/token"
109+
export FERN_AUTH_CLIENT_ID="your-client-id"
110+
export FERN_AUTH_CLIENT_SECRET="your-client-secret"
111+
112+
fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "report.xml"
113+
```
114+
115+
##### With OAuth and Scopes
116+
```sh
117+
export AUTH_URL="https://oauth.example.com/token"
118+
export FERN_AUTH_CLIENT_ID="your-client-id"
119+
export FERN_AUTH_CLIENT_SECRET="your-client-secret"
120+
export FERN_CLIENT_SCOPE="read write admin"
121+
122+
fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "report.xml"
123+
```
124+
125+
##### Verbose Mode
126+
Use the `--verbose` flag to see OAuth authentication status:
127+
```sh
128+
fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "report.xml" --verbose
129+
```
130+
57131
## See Also
58132

59133
* [Fern UI](https://github.com/guidewire-oss/fern-ui)
60-
* [Fern Reporter](https://github.com/guidewire-oss/fern-reporter)
134+
* [Fern Platform](https://github.com/guidewire-oss/fern-platform)
61135
* [Fern Ginkgo Client](https://github.com/guidewire-oss/fern-ginkgo-client)
62136

63137
## Development

cmd/send.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ var sendCmd = &cobra.Command{
2929
}
3030

3131
func init() {
32-
sendCmd.PersistentFlags().StringVarP(&fernUrl, "fern-url", "u", "", "base URL of the Fern Reporter instance to send test reports to (required)")
33-
sendCmd.PersistentFlags().StringVarP(&projectId, "project-id", "p", "", "Id of the project to associate test reports with (required). You must register the application first in fern-reporter")
32+
sendCmd.PersistentFlags().StringVarP(&fernUrl, "fern-url", "u", "", "base URL of the Fern Platform instance to send test reports to (required)")
33+
sendCmd.PersistentFlags().StringVarP(&projectId, "project-id", "p", "", "Id of the project to associate test reports with (required). You must register the application first in Fern Platform")
3434
sendCmd.PersistentFlags().StringVarP(&filePattern, "file-pattern", "f", "", "file name pattern of test reports to send to Fern (required)")
3535
sendCmd.PersistentFlags().StringVarP(&tags, "tags", "t", "", "comma-separated tags to be included on runs")
3636
if err := sendCmd.MarkPersistentFlagRequired("fern-url"); err != nil {

pkg/auth/oauth.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package auth
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
"os"
9+
"strings"
10+
"time"
11+
)
12+
13+
// OAuthConfig holds OAuth configuration
14+
type OAuthConfig struct {
15+
TokenURL string
16+
ClientID string
17+
ClientPassword string
18+
Scopes string
19+
}
20+
21+
// TokenResponse represents the OAuth token response
22+
type TokenResponse struct {
23+
AccessToken string `json:"access_token"`
24+
TokenType string `json:"token_type"`
25+
ExpiresIn int `json:"expires_in"`
26+
Scope string `json:"scope,omitempty"`
27+
}
28+
29+
// OAuthClient handles OAuth authentication
30+
type OAuthClient struct {
31+
config *OAuthConfig
32+
token *TokenResponse
33+
tokenExpiry time.Time
34+
}
35+
36+
// NewOAuthClient creates a new OAuth client
37+
// Returns nil if OAuth is not configured (no AUTH_URL set)
38+
// Returns an error if OAuth is partially configured (missing required parameters)
39+
func NewOAuthClient() (*OAuthClient, error) {
40+
config := &OAuthConfig{
41+
TokenURL: os.Getenv("AUTH_URL"),
42+
ClientID: os.Getenv("FERN_AUTH_CLIENT_ID"),
43+
ClientPassword: os.Getenv("FERN_AUTH_CLIENT_SECRET"),
44+
Scopes: os.Getenv("FERN_CLIENT_SCOPE"),
45+
}
46+
47+
// If token URL is not set, OAuth is disabled - this is OK
48+
if config.TokenURL == "" {
49+
return nil, nil
50+
}
51+
52+
// If AUTH_URL is set, validate that we have all required OAuth parameters
53+
var missingParams []string
54+
if config.ClientID == "" {
55+
missingParams = append(missingParams, "FERN_AUTH_CLIENT_ID")
56+
}
57+
if config.ClientPassword == "" {
58+
missingParams = append(missingParams, "FERN_AUTH_CLIENT_SECRET")
59+
}
60+
61+
if len(missingParams) > 0 {
62+
return nil, fmt.Errorf("OAuth configuration error: AUTH_URL is set but missing required parameters: %s", strings.Join(missingParams, ", "))
63+
}
64+
65+
return &OAuthClient{
66+
config: config,
67+
}, nil
68+
}
69+
70+
// GetToken fetches a new OAuth token or returns the cached one if still valid
71+
func (c *OAuthClient) GetToken() (string, error) {
72+
if c == nil {
73+
return "", nil
74+
}
75+
76+
// Check if we have a valid cached token
77+
if c.token != nil && time.Now().Before(c.tokenExpiry) {
78+
return c.token.AccessToken, nil
79+
}
80+
81+
// Fetch new token
82+
if err := c.fetchToken(); err != nil {
83+
return "", fmt.Errorf("failed to fetch OAuth token: %w", err)
84+
}
85+
86+
return c.token.AccessToken, nil
87+
}
88+
89+
// fetchToken fetches a new OAuth token from the authorization server
90+
func (c *OAuthClient) fetchToken() error {
91+
// Prepare the token request
92+
data := url.Values{}
93+
data.Set("grant_type", "client_credentials")
94+
data.Set("client_id", c.config.ClientID)
95+
data.Set("client_secret", c.config.ClientPassword)
96+
97+
// Add scopes if provided
98+
if c.config.Scopes != "" {
99+
data.Set("scope", c.config.Scopes)
100+
}
101+
102+
req, err := http.NewRequest("POST", c.config.TokenURL, strings.NewReader(data.Encode()))
103+
if err != nil {
104+
return fmt.Errorf("failed to create token request: %w", err)
105+
}
106+
107+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
108+
109+
// Send the request
110+
client := &http.Client{
111+
Timeout: 30 * time.Second,
112+
}
113+
resp, err := client.Do(req)
114+
if err != nil {
115+
return fmt.Errorf("failed to send token request: %w", err)
116+
}
117+
defer resp.Body.Close()
118+
119+
if resp.StatusCode != http.StatusOK {
120+
return fmt.Errorf("token request failed with status: %d", resp.StatusCode)
121+
}
122+
123+
// Parse the response
124+
var tokenResp TokenResponse
125+
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
126+
return fmt.Errorf("failed to decode token response: %w", err)
127+
}
128+
129+
// Store the token and calculate expiry
130+
c.token = &tokenResp
131+
// Subtract 30 seconds from expiry to ensure we refresh before it actually expires
132+
expiryDuration := time.Duration(tokenResp.ExpiresIn-30) * time.Second
133+
c.tokenExpiry = time.Now().Add(expiryDuration)
134+
135+
return nil
136+
}
137+
138+
// AddAuthHeader adds the OAuth bearer token to the request header if OAuth is enabled
139+
func (c *OAuthClient) AddAuthHeader(req *http.Request) error {
140+
if c == nil {
141+
// OAuth is not enabled
142+
return nil
143+
}
144+
145+
token, err := c.GetToken()
146+
if err != nil {
147+
return err
148+
}
149+
150+
if token != "" {
151+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
152+
}
153+
154+
return nil
155+
}
156+
157+
// IsEnabled returns whether OAuth is enabled
158+
func (c *OAuthClient) IsEnabled() bool {
159+
return c != nil
160+
}
161+
162+
// HTTPClient returns an http.Client with OAuth authentication if enabled
163+
func (c *OAuthClient) HTTPClient() *http.Client {
164+
return &http.Client{
165+
Timeout: 30 * time.Second,
166+
Transport: &OAuthTransport{
167+
Base: http.DefaultTransport,
168+
OAuthClient: c,
169+
},
170+
}
171+
}
172+
173+
// OAuthTransport is an http.RoundTripper that adds OAuth authentication
174+
type OAuthTransport struct {
175+
Base http.RoundTripper
176+
OAuthClient *OAuthClient
177+
}
178+
179+
// RoundTrip implements the http.RoundTripper interface
180+
func (t *OAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
181+
// Clone the request to avoid modifying the original
182+
reqCopy := req.Clone(req.Context())
183+
184+
// Add OAuth header
185+
if err := t.OAuthClient.AddAuthHeader(reqCopy); err != nil {
186+
return nil, err
187+
}
188+
189+
// Use the base transport to send the request
190+
return t.Base.RoundTrip(reqCopy)
191+
}

0 commit comments

Comments
 (0)