mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
943385105f
io.ReadAll errors were silently discarded with `body, _ := io.ReadAll(...)`, which could cause empty or partial data to be used for JSON unmarshaling or error messages. This adds proper error checks for all instances.
638 lines
18 KiB
Go
638 lines
18 KiB
Go
package auth
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type OAuthProviderConfig struct {
|
|
Issuer string
|
|
ClientID string
|
|
ClientSecret string // Required for Google OAuth (confidential client)
|
|
TokenURL string // Override token endpoint (Google uses a different URL than issuer)
|
|
Scopes string
|
|
Originator string
|
|
Port int
|
|
}
|
|
|
|
func OpenAIOAuthConfig() OAuthProviderConfig {
|
|
return OAuthProviderConfig{
|
|
Issuer: "https://auth.openai.com",
|
|
ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
Scopes: "openid profile email offline_access",
|
|
Originator: "codex_cli_rs",
|
|
Port: 1455,
|
|
}
|
|
}
|
|
|
|
// GoogleAntigravityOAuthConfig returns the OAuth configuration for Google Cloud Code Assist (Antigravity).
|
|
// Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access.
|
|
func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
|
|
// These are the same client credentials used by the OpenCode antigravity plugin.
|
|
clientID := decodeBase64(
|
|
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
|
)
|
|
clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=")
|
|
return OAuthProviderConfig{
|
|
Issuer: "https://accounts.google.com/o/oauth2/v2",
|
|
TokenURL: "https://oauth2.googleapis.com/token",
|
|
ClientID: clientID,
|
|
ClientSecret: clientSecret,
|
|
Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs",
|
|
Port: 51121,
|
|
}
|
|
}
|
|
|
|
func decodeBase64(s string) string {
|
|
data, err := base64.StdEncoding.DecodeString(s)
|
|
if err != nil {
|
|
return s
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
// GenerateState generates a random state string for OAuth CSRF protection.
|
|
func GenerateState() (string, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|
pkce, err := GeneratePKCE()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generating PKCE: %w", err)
|
|
}
|
|
|
|
state, err := GenerateState()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generating state: %w", err)
|
|
}
|
|
|
|
redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", cfg.Port)
|
|
|
|
authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI)
|
|
|
|
resultCh := make(chan callbackResult, 1)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("state") != state {
|
|
resultCh <- callbackResult{err: fmt.Errorf("state mismatch")}
|
|
http.Error(w, "State mismatch", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
code := r.URL.Query().Get("code")
|
|
if code == "" {
|
|
errMsg := r.URL.Query().Get("error")
|
|
resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)}
|
|
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
fmt.Fprint(w, "<html><body><h2>Authentication successful!</h2><p>You can close this window.</p></body></html>")
|
|
resultCh <- callbackResult{code: code}
|
|
})
|
|
|
|
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err)
|
|
}
|
|
|
|
server := &http.Server{Handler: mux}
|
|
go server.Serve(listener)
|
|
defer func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
server.Shutdown(ctx)
|
|
}()
|
|
|
|
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
|
|
|
if err := OpenBrowser(authURL); err != nil {
|
|
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
|
}
|
|
|
|
fmt.Printf(
|
|
"Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n",
|
|
cfg.Port,
|
|
)
|
|
fmt.Println(
|
|
"please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.",
|
|
)
|
|
fmt.Println("Waiting for authentication (browser or manual paste)...")
|
|
|
|
// Start manual input in a goroutine
|
|
manualCh := make(chan string)
|
|
go func() {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
input, _ := reader.ReadString('\n')
|
|
manualCh <- strings.TrimSpace(input)
|
|
}()
|
|
|
|
select {
|
|
case result := <-resultCh:
|
|
if result.err != nil {
|
|
return nil, result.err
|
|
}
|
|
return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
|
case manualInput := <-manualCh:
|
|
if manualInput == "" {
|
|
return nil, fmt.Errorf("manual input canceled")
|
|
}
|
|
// Extract code from URL if it's a full URL
|
|
code := manualInput
|
|
if strings.Contains(manualInput, "?") {
|
|
u, err := url.Parse(manualInput)
|
|
if err == nil {
|
|
code = u.Query().Get("code")
|
|
}
|
|
}
|
|
if code == "" {
|
|
return nil, fmt.Errorf("could not find authorization code in input")
|
|
}
|
|
return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
|
case <-time.After(5 * time.Minute):
|
|
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
|
}
|
|
}
|
|
|
|
type callbackResult struct {
|
|
code string
|
|
err error
|
|
}
|
|
|
|
type deviceCodeResponse struct {
|
|
DeviceAuthID string
|
|
UserCode string
|
|
Interval int
|
|
}
|
|
|
|
// DeviceCodeInfo holds the device code information returned by the OAuth provider.
|
|
type DeviceCodeInfo struct {
|
|
DeviceAuthID string `json:"device_auth_id"`
|
|
UserCode string `json:"user_code"`
|
|
VerifyURL string `json:"verify_url"`
|
|
Interval int `json:"interval"`
|
|
}
|
|
|
|
// RequestDeviceCode requests a device code from the OAuth provider.
|
|
// Returns the info needed for the user to authenticate in a browser.
|
|
func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
|
|
reqBody, _ := json.Marshal(map[string]string{
|
|
"client_id": cfg.ClientID,
|
|
})
|
|
|
|
resp, err := http.Post(
|
|
cfg.Issuer+"/api/accounts/deviceauth/usercode",
|
|
"application/json",
|
|
strings.NewReader(string(reqBody)),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("requesting device code: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
|
}
|
|
|
|
deviceResp, err := parseDeviceCodeResponse(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing device code response: %w", err)
|
|
}
|
|
|
|
if deviceResp.Interval < 1 {
|
|
deviceResp.Interval = 5
|
|
}
|
|
|
|
return &DeviceCodeInfo{
|
|
DeviceAuthID: deviceResp.DeviceAuthID,
|
|
UserCode: deviceResp.UserCode,
|
|
VerifyURL: cfg.Issuer + "/codex/device",
|
|
Interval: deviceResp.Interval,
|
|
}, nil
|
|
}
|
|
|
|
// PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated.
|
|
// Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure.
|
|
func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
|
|
return pollDeviceCode(cfg, deviceAuthID, userCode)
|
|
}
|
|
|
|
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
|
var raw struct {
|
|
DeviceAuthID string `json:"device_auth_id"`
|
|
UserCode string `json:"user_code"`
|
|
Interval json.RawMessage `json:"interval"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return deviceCodeResponse{}, err
|
|
}
|
|
|
|
interval, err := parseFlexibleInt(raw.Interval)
|
|
if err != nil {
|
|
return deviceCodeResponse{}, err
|
|
}
|
|
|
|
return deviceCodeResponse{
|
|
DeviceAuthID: raw.DeviceAuthID,
|
|
UserCode: raw.UserCode,
|
|
Interval: interval,
|
|
}, nil
|
|
}
|
|
|
|
func parseFlexibleInt(raw json.RawMessage) (int, error) {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return 0, nil
|
|
}
|
|
|
|
var interval int
|
|
if err := json.Unmarshal(raw, &interval); err == nil {
|
|
return interval, nil
|
|
}
|
|
|
|
var intervalStr string
|
|
if err := json.Unmarshal(raw, &intervalStr); err == nil {
|
|
intervalStr = strings.TrimSpace(intervalStr)
|
|
if intervalStr == "" {
|
|
return 0, nil
|
|
}
|
|
return strconv.Atoi(intervalStr)
|
|
}
|
|
|
|
return 0, fmt.Errorf("invalid integer value: %s", string(raw))
|
|
}
|
|
|
|
func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|
reqBody, _ := json.Marshal(map[string]string{
|
|
"client_id": cfg.ClientID,
|
|
})
|
|
|
|
resp, err := http.Post(
|
|
cfg.Issuer+"/api/accounts/deviceauth/usercode",
|
|
"application/json",
|
|
strings.NewReader(string(reqBody)),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("requesting device code: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
|
}
|
|
|
|
deviceResp, err := parseDeviceCodeResponse(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing device code response: %w", err)
|
|
}
|
|
|
|
if deviceResp.Interval < 1 {
|
|
deviceResp.Interval = 5
|
|
}
|
|
|
|
fmt.Printf(
|
|
"\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
|
|
cfg.Issuer,
|
|
deviceResp.UserCode,
|
|
)
|
|
|
|
deadline := time.After(15 * time.Minute)
|
|
ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-deadline:
|
|
return nil, fmt.Errorf("device code authentication timed out after 15 minutes")
|
|
case <-ticker.C:
|
|
cred, err := pollDeviceCode(cfg, deviceResp.DeviceAuthID, deviceResp.UserCode)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if cred != nil {
|
|
return cred, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
|
|
reqBody, _ := json.Marshal(map[string]string{
|
|
"device_auth_id": deviceAuthID,
|
|
"user_code": userCode,
|
|
})
|
|
|
|
resp, err := http.Post(
|
|
cfg.Issuer+"/api/accounts/deviceauth/token",
|
|
"application/json",
|
|
strings.NewReader(string(reqBody)),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("pending")
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading device token response: %w", err)
|
|
}
|
|
|
|
var tokenResp struct {
|
|
AuthorizationCode string `json:"authorization_code"`
|
|
CodeChallenge string `json:"code_challenge"`
|
|
CodeVerifier string `json:"code_verifier"`
|
|
}
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
|
return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
|
}
|
|
|
|
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|
if cred.RefreshToken == "" {
|
|
return nil, fmt.Errorf("no refresh token available")
|
|
}
|
|
|
|
data := url.Values{
|
|
"client_id": {cfg.ClientID},
|
|
"grant_type": {"refresh_token"},
|
|
"refresh_token": {cred.RefreshToken},
|
|
"scope": {"openid profile email"},
|
|
}
|
|
if cfg.ClientSecret != "" {
|
|
data.Set("client_secret", cfg.ClientSecret)
|
|
}
|
|
|
|
tokenURL := cfg.Issuer + "/oauth/token"
|
|
if cfg.TokenURL != "" {
|
|
tokenURL = cfg.TokenURL
|
|
}
|
|
|
|
resp, err := http.PostForm(tokenURL, data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refreshing token: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading token refresh response: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
|
}
|
|
|
|
refreshed, err := parseTokenResponse(body, cred.Provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if refreshed.RefreshToken == "" {
|
|
refreshed.RefreshToken = cred.RefreshToken
|
|
}
|
|
if refreshed.AccountID == "" {
|
|
refreshed.AccountID = cred.AccountID
|
|
}
|
|
if cred.Email != "" && refreshed.Email == "" {
|
|
refreshed.Email = cred.Email
|
|
}
|
|
if cred.ProjectID != "" && refreshed.ProjectID == "" {
|
|
refreshed.ProjectID = cred.ProjectID
|
|
}
|
|
return refreshed, nil
|
|
}
|
|
|
|
func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
|
|
return buildAuthorizeURL(cfg, pkce, state, redirectURI)
|
|
}
|
|
|
|
func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
|
|
params := url.Values{
|
|
"response_type": {"code"},
|
|
"client_id": {cfg.ClientID},
|
|
"redirect_uri": {redirectURI},
|
|
"scope": {cfg.Scopes},
|
|
"code_challenge": {pkce.CodeChallenge},
|
|
"code_challenge_method": {"S256"},
|
|
"state": {state},
|
|
}
|
|
|
|
isGoogle := strings.Contains(strings.ToLower(cfg.Issuer), "accounts.google.com")
|
|
if isGoogle {
|
|
// Google OAuth requires these for refresh token support
|
|
params.Set("access_type", "offline")
|
|
params.Set("prompt", "consent")
|
|
} else {
|
|
// OpenAI-specific parameters
|
|
params.Set("id_token_add_organizations", "true")
|
|
params.Set("codex_cli_simplified_flow", "true")
|
|
if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
|
|
params.Set("originator", "picoclaw")
|
|
}
|
|
if cfg.Originator != "" {
|
|
params.Set("originator", cfg.Originator)
|
|
}
|
|
}
|
|
|
|
// Google uses /auth path, OpenAI uses /oauth/authorize
|
|
if isGoogle {
|
|
return cfg.Issuer + "/auth?" + params.Encode()
|
|
}
|
|
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
|
}
|
|
|
|
// ExchangeCodeForTokens exchanges an authorization code for tokens.
|
|
func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
|
data := url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"code": {code},
|
|
"redirect_uri": {redirectURI},
|
|
"client_id": {cfg.ClientID},
|
|
"code_verifier": {codeVerifier},
|
|
}
|
|
if cfg.ClientSecret != "" {
|
|
data.Set("client_secret", cfg.ClientSecret)
|
|
}
|
|
|
|
tokenURL := cfg.Issuer + "/oauth/token"
|
|
if cfg.TokenURL != "" {
|
|
tokenURL = cfg.TokenURL
|
|
}
|
|
|
|
// Determine provider name from config
|
|
provider := "openai"
|
|
if cfg.TokenURL != "" && strings.Contains(cfg.TokenURL, "googleapis.com") {
|
|
provider = "google-antigravity"
|
|
}
|
|
|
|
resp, err := http.PostForm(tokenURL, data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("exchanging code for tokens: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading token exchange response: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
|
}
|
|
|
|
return parseTokenResponse(body, provider)
|
|
}
|
|
|
|
func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
|
|
var tokenResp struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
IDToken string `json:"id_token"`
|
|
}
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
return nil, fmt.Errorf("parsing token response: %w", err)
|
|
}
|
|
|
|
if tokenResp.AccessToken == "" {
|
|
return nil, fmt.Errorf("no access token in response")
|
|
}
|
|
|
|
var expiresAt time.Time
|
|
if tokenResp.ExpiresIn > 0 {
|
|
expiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
|
}
|
|
|
|
cred := &AuthCredential{
|
|
AccessToken: tokenResp.AccessToken,
|
|
RefreshToken: tokenResp.RefreshToken,
|
|
ExpiresAt: expiresAt,
|
|
Provider: provider,
|
|
AuthMethod: "oauth",
|
|
}
|
|
|
|
if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
|
|
cred.AccountID = accountID
|
|
} else if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" {
|
|
cred.AccountID = accountID
|
|
} else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
|
|
// Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims.
|
|
cred.AccountID = accountID
|
|
}
|
|
|
|
return cred, nil
|
|
}
|
|
|
|
func extractAccountID(token string) string {
|
|
claims, err := parseJWTClaims(token)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
if accountID, ok := claims["chatgpt_account_id"].(string); ok && accountID != "" {
|
|
return accountID
|
|
}
|
|
|
|
if accountID, ok := claims["https://api.openai.com/auth.chatgpt_account_id"].(string); ok && accountID != "" {
|
|
return accountID
|
|
}
|
|
|
|
if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]any); ok {
|
|
if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" {
|
|
return accountID
|
|
}
|
|
}
|
|
|
|
if orgs, ok := claims["organizations"].([]any); ok {
|
|
for _, org := range orgs {
|
|
if orgMap, ok := org.(map[string]any); ok {
|
|
if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
|
|
return accountID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func parseJWTClaims(token string) (map[string]any, error) {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) < 2 {
|
|
return nil, fmt.Errorf("token is not a JWT")
|
|
}
|
|
|
|
payload := parts[1]
|
|
switch len(payload) % 4 {
|
|
case 2:
|
|
payload += "=="
|
|
case 3:
|
|
payload += "="
|
|
}
|
|
|
|
decoded, err := base64URLDecode(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var claims map[string]any
|
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
func base64URLDecode(s string) ([]byte, error) {
|
|
s = strings.NewReplacer("-", "+", "_", "/").Replace(s)
|
|
return base64.StdEncoding.DecodeString(s)
|
|
}
|
|
|
|
// OpenBrowser opens the given URL in the user's default browser.
|
|
func OpenBrowser(url string) error {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
return exec.Command("open", url).Start()
|
|
case "linux":
|
|
return exec.Command("xdg-open", url).Start()
|
|
case "windows":
|
|
return exec.Command("cmd", "/c", "start", url).Start()
|
|
default:
|
|
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
|
}
|
|
}
|