2026-03-12 23:49:16 +00:00
|
|
|
package oauth
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/Gleipnir-Technology/arcgis-go"
|
|
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/config"
|
|
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/db"
|
2026-05-01 17:28:33 +00:00
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/db/gen/nidus-sync/arcgis/model"
|
2026-03-12 23:49:16 +00:00
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/db/models"
|
2026-05-01 17:28:33 +00:00
|
|
|
queryarcgis "github.com/Gleipnir-Technology/nidus-sync/db/query/arcgis"
|
2026-03-12 23:49:16 +00:00
|
|
|
"github.com/rs/zerolog/log"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// When the API responds that the token is now invalidated
|
|
|
|
|
type InvalidatedTokenError struct{}
|
|
|
|
|
|
|
|
|
|
func (e InvalidatedTokenError) Error() string { return "The token has been invalidated by the server" }
|
|
|
|
|
|
|
|
|
|
type OAuthTokenResponse struct {
|
|
|
|
|
AccessToken string `json:"access_token"`
|
|
|
|
|
ExpiresIn int `json:"expires_in"`
|
|
|
|
|
RefreshToken string `json:"refresh_token"`
|
|
|
|
|
RefreshTokenExpiresIn int `json:"refresh_token_expires_in"`
|
|
|
|
|
SSL bool `json:"ssl"`
|
|
|
|
|
Username string `json:"username"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func DoTokenRequest(ctx context.Context, form url.Values) (*OAuthTokenResponse, error) {
|
|
|
|
|
form.Set("client_id", config.ClientID)
|
|
|
|
|
|
|
|
|
|
baseURL := "https://www.arcgis.com/sharing/rest/oauth2/token/"
|
|
|
|
|
req, err := http.NewRequest("POST", baseURL, strings.NewReader(form.Encode()))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("Failed to create request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
|
client := http.Client{}
|
|
|
|
|
log.Info().Str("url", req.URL.String()).Msg("POST")
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("Failed to do request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
|
|
|
log.Info().Int("status", resp.StatusCode).Msg("Token request")
|
|
|
|
|
if resp.StatusCode >= http.StatusBadRequest {
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("Got status code %d and failed to read response body: %w", resp.StatusCode, err)
|
|
|
|
|
}
|
|
|
|
|
bodyString := string(bodyBytes)
|
|
|
|
|
var errorResp arcgis.ErrorResponse
|
|
|
|
|
if err := json.Unmarshal(bodyBytes, &errorResp); err == nil {
|
|
|
|
|
if errorResp.Error.Code == 498 && errorResp.Error.Description == "invalidated refresh_token" {
|
|
|
|
|
return nil, InvalidatedTokenError{}
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("API response JSON error: %d: %d %s", resp.StatusCode, errorResp.Error.Code, errorResp.Error.Description)
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("API returned error status %d: %s", resp.StatusCode, bodyString)
|
|
|
|
|
}
|
|
|
|
|
//logResponseHeaders(resp)
|
|
|
|
|
var tokenResponse OAuthTokenResponse
|
|
|
|
|
err = json.Unmarshal(bodyBytes, &tokenResponse)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("Failed to unmarshal JSON: %w", err)
|
|
|
|
|
}
|
|
|
|
|
// Just because we got a 200-level status code doesn't mean it worked. Experience has taught us that
|
|
|
|
|
// we can get errors without anything indicated in the headers or the status code
|
|
|
|
|
if tokenResponse == (OAuthTokenResponse{}) {
|
|
|
|
|
var errorResponse arcgis.ErrorResponse
|
|
|
|
|
err = json.Unmarshal(bodyBytes, &errorResponse)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("Failed to unmarshal error JSON: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if errorResponse.Error.Code > 0 {
|
|
|
|
|
return nil, errorResponse.AsError(ctx)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
log.Info().Str("refresh token", tokenResponse.RefreshToken).Str("access token", tokenResponse.AccessToken).Int("access expires", tokenResponse.ExpiresIn).Int("refresh expires", tokenResponse.RefreshTokenExpiresIn).Msg("Oauth token acquired")
|
|
|
|
|
return &tokenResponse, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func FutureUTCTimestamp(secondsFromNow int) time.Time {
|
|
|
|
|
return time.Now().UTC().Add(time.Duration(secondsFromNow) * time.Second)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 17:28:33 +00:00
|
|
|
func GetOAuthForOrg(ctx context.Context, org *models.Organization) (*model.OAuthToken, error) {
|
2026-03-12 23:49:16 +00:00
|
|
|
users, err := org.User().All(ctx, db.PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("Failed to query all users for org: %w", err)
|
|
|
|
|
}
|
|
|
|
|
for _, user := range users {
|
2026-05-01 17:28:33 +00:00
|
|
|
oauths, err := queryarcgis.OAuthTokensForUser(ctx, int64(user.ID))
|
2026-03-12 23:49:16 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("Failed to query all oauth tokens for org: %w", err)
|
|
|
|
|
}
|
|
|
|
|
for _, oauth := range oauths {
|
2026-05-07 10:39:17 +00:00
|
|
|
return &oauth, nil
|
2026-03-12 23:49:16 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update the access token to keep it fresh and alive
|
2026-05-01 17:28:33 +00:00
|
|
|
func RefreshAccessToken(ctx context.Context, oauth *model.OAuthToken) error {
|
2026-03-12 23:49:16 +00:00
|
|
|
form := url.Values{
|
|
|
|
|
"grant_type": []string{"refresh_token"},
|
|
|
|
|
"client_id": []string{config.ClientID},
|
|
|
|
|
"refresh_token": []string{oauth.RefreshToken},
|
|
|
|
|
}
|
|
|
|
|
token, err := DoTokenRequest(ctx, form)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("Failed to handle request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
accessExpires := FutureUTCTimestamp(token.ExpiresIn)
|
2026-05-01 17:28:33 +00:00
|
|
|
model := model.OAuthToken{
|
|
|
|
|
AccessToken: token.AccessToken,
|
|
|
|
|
AccessTokenExpires: accessExpires,
|
|
|
|
|
Username: token.Username,
|
2026-03-12 23:49:16 +00:00
|
|
|
}
|
2026-05-07 10:39:17 +00:00
|
|
|
err = queryarcgis.OAuthTokenUpdateAccessToken(ctx, int64(oauth.ID), model)
|
2026-03-12 23:49:16 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("Failed to update oauth in database: %w", err)
|
|
|
|
|
}
|
|
|
|
|
log.Info().Int("oauth token id", int(oauth.ID)).Msg("Updated oauth token")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update the refresh token to keep it fresh and alive
|
2026-05-01 17:28:33 +00:00
|
|
|
func RefreshRefreshToken(ctx context.Context, oauth *model.OAuthToken) error {
|
2026-03-12 23:49:16 +00:00
|
|
|
|
|
|
|
|
form := url.Values{
|
|
|
|
|
"grant_type": []string{"exchange_refresh_token"},
|
|
|
|
|
"redirect_uri": []string{config.ArcGISOauthRedirectURL()},
|
|
|
|
|
"refresh_token": []string{oauth.RefreshToken},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
token, err := DoTokenRequest(ctx, form)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("Failed to handle request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
refreshExpires := FutureUTCTimestamp(token.ExpiresIn)
|
2026-05-01 17:28:33 +00:00
|
|
|
model := model.OAuthToken{
|
|
|
|
|
RefreshToken: token.RefreshToken,
|
|
|
|
|
RefreshTokenExpires: refreshExpires,
|
|
|
|
|
Username: token.Username,
|
2026-03-12 23:49:16 +00:00
|
|
|
}
|
2026-05-07 10:39:17 +00:00
|
|
|
err = queryarcgis.OAuthTokenUpdateRefreshToken(ctx, int64(oauth.ID), model)
|
2026-03-12 23:49:16 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("Failed to update oauth in database: %w", err)
|
|
|
|
|
}
|
|
|
|
|
log.Info().Int("oauth token id", int(oauth.ID)).Msg("Updated oauth token")
|
|
|
|
|
return nil
|
|
|
|
|
}
|