Skip to content

Commit 3e8113c

Browse files
Add SSO auth token implementation (#697)
* Add SSO auth token implementation * Add note in CHANGELOG file * addressed copilot comments
1 parent 7d8e21b commit 3e8113c

File tree

2 files changed

+105
-7
lines changed

2 files changed

+105
-7
lines changed

v2/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- Add tasks endpoints to v2
55
- Add missing endpoints from collections to v2
66
- Add missing endpoints from query to v2
7+
- Add SSO auth token implementation
78

89
## [2.1.3](https://github.com/arangodb/go-driver/tree/v2.1.3) (2025-02-21)
910
- Switch to Go 1.22.11

v2/connection/auth_jwt_impl.go

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,20 @@ package connection
2424

2525
import (
2626
"context"
27+
"encoding/base64"
28+
"encoding/json"
29+
"fmt"
30+
"log"
2731
"net/http"
32+
"strings"
33+
"time"
2834
)
2935

3036
func NewJWTAuthWrapper(username, password string) Wrapper {
31-
return WrapAuthentication(func(ctx context.Context, conn Connection) (authentication Authentication, err error) {
37+
var token string
38+
var expiry time.Time
39+
40+
refresh := func(ctx context.Context, conn Connection) error {
3241
url := NewUrl("_open", "auth")
3342

3443
var data jwtOpenResponse
@@ -40,15 +49,31 @@ func NewJWTAuthWrapper(username, password string) Wrapper {
4049

4150
resp, err := CallPost(ctx, conn, url, &data, j)
4251
if err != nil {
43-
return nil, err
52+
return err
53+
}
54+
if resp.Code() != http.StatusOK {
55+
return NewError(resp.Code(), "unexpected code")
56+
}
57+
58+
token = data.Token
59+
expiry, err = parseJWTExpiry(token)
60+
if err != nil {
61+
// Log for visibility but don't break functionality
62+
log.Printf("failed to parse JWT expiry: %v", err)
63+
expiry = time.Now().Add(1 * time.Minute) // fallback, so it will refresh immediately next time
4464
}
65+
return nil
66+
}
4567

46-
switch resp.Code() {
47-
case http.StatusOK:
48-
return NewHeaderAuth("Authorization", "bearer %s", data.Token), nil
49-
default:
50-
return nil, NewError(resp.Code(), "unexpected code")
68+
return WrapAuthentication(func(ctx context.Context, conn Connection) (Authentication, error) {
69+
// First time fetch
70+
if token == "" || time.Now().After(expiry) {
71+
if err := refresh(ctx, conn); err != nil {
72+
return nil, err
73+
}
5174
}
75+
76+
return NewHeaderAuth("Authorization", "bearer %s", token), nil
5277
})
5378
}
5479

@@ -59,5 +84,77 @@ type jwtOpenRequest struct {
5984

6085
type jwtOpenResponse struct {
6186
Token string `json:"jwt"`
87+
ExpiresIn int `json:"expires_in,omitempty"`
6288
MustChangePassword bool `json:"must_change_password,omitempty"`
6389
}
90+
91+
func parseJWTExpiry(token string) (time.Time, error) {
92+
parts := strings.Split(token, ".")
93+
if len(parts) < 2 {
94+
return time.Time{}, fmt.Errorf("invalid JWT format")
95+
}
96+
97+
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
98+
if err != nil {
99+
return time.Time{}, err
100+
}
101+
102+
var claims struct {
103+
Exp int64 `json:"exp"`
104+
}
105+
if err := json.Unmarshal(payload, &claims); err != nil {
106+
return time.Time{}, err
107+
}
108+
109+
return time.Unix(claims.Exp, 0), nil
110+
}
111+
112+
func NewSSOAuthWrapper(initialToken string) Wrapper {
113+
var token = initialToken
114+
var expiry time.Time
115+
// setToken updates the current JWT and its expiry time.
116+
// If expiry parsing fails, we log the error and fall back to a short 1-minute lifetime.
117+
// This ensures the token will be refreshed soon without breaking functionality.
118+
setToken := func(newToken string) {
119+
token = newToken
120+
expiryTime, err := parseJWTExpiry(newToken)
121+
if err != nil {
122+
// Log for visibility but don't break functionality
123+
log.Printf("failed to parse JWT expiry: %v", err)
124+
expiry = time.Now().Add(1 * time.Minute) // fallback, so it will refresh immediately next time
125+
} else {
126+
expiry = expiryTime
127+
}
128+
}
129+
130+
// If we already have a token (from an SSO login), parse expiry now
131+
if token != "" {
132+
setToken(token)
133+
}
134+
135+
return WrapAuthentication(func(ctx context.Context, conn Connection) (Authentication, error) {
136+
// No token yet or expired — let caller know they must login via SSO
137+
if token == "" || time.Now().After(expiry) {
138+
// Try a call to _open/auth just to see if server sends 307
139+
url := NewUrl("_open", "auth")
140+
var data jwtOpenResponse
141+
// Intentionally passing nil: in SSO mode, /_open/auth expects no body
142+
resp, err := CallPost(ctx, conn, url, &data, nil)
143+
if err != nil {
144+
return nil, err
145+
}
146+
147+
switch resp.Code() {
148+
case http.StatusOK:
149+
setToken(data.Token)
150+
case http.StatusTemporaryRedirect:
151+
loc := resp.Header("Location")
152+
return nil, fmt.Errorf("SSO redirect: please authenticate via browser at %s", loc)
153+
default:
154+
return nil, NewError(resp.Code(), "unexpected code")
155+
}
156+
}
157+
158+
return NewHeaderAuth("Authorization", "bearer %s", token), nil
159+
})
160+
}

0 commit comments

Comments
 (0)