only require needed for oauth import

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-12-14 20:43:36 +01:00
parent 5102f0ac11
commit 428941ee77
7 changed files with 225 additions and 44 deletions

View File

@@ -22,7 +22,7 @@ type OAuthProvider struct {
// oauth endpoints (user configurable)
AuthURL string `gorm:"not null;type:varchar(512);"`
TokenURL string `gorm:"not null;type:varchar(512);"`
Scopes string `gorm:"not null;type:varchar(512);"`
Scopes string `gorm:"not null;type:varchar(2048);"`
// user's oauth app credentials (stored as plain text like smtp passwords)
ClientID string `gorm:"not null;type:varchar(255);"`

View File

@@ -7,37 +7,25 @@ import (
// ImportAuthorizedToken represents an imported oauth token
type ImportAuthorizedToken struct {
AccessToken string `json:"access_token"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token"`
ClientID string `json:"client_id"`
ExpiresAt int64 `json:"expires_at"` // unix timestamp in milliseconds
Name string `json:"name"`
User string `json:"user"`
Scope string `json:"scope"`
ExpiresAt int64 `json:"expires_at,omitempty"` // unix timestamp in milliseconds
Name string `json:"name,omitempty"`
User string `json:"user,omitempty"`
Scope string `json:"scope,omitempty"`
TokenURL string `json:"token_url,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
}
// Validate checks if the imported token has a valid state
func (i *ImportAuthorizedToken) Validate() error {
if i.AccessToken == "" {
return validate.WrapErrorWithField(errors.New("is required"), "access_token")
}
if i.RefreshToken == "" {
return validate.WrapErrorWithField(errors.New("is required"), "refresh_token")
}
if i.Name == "" {
return validate.WrapErrorWithField(errors.New("is required"), "name")
}
if i.ExpiresAt == 0 {
return validate.WrapErrorWithField(errors.New("is required"), "expires_at")
}
if i.ClientID == "" {
return validate.WrapErrorWithField(errors.New("is required"), "client_id")
}
if i.Scope == "" {
return validate.WrapErrorWithField(errors.New("is required"), "scope")
}
return nil
}

View File

@@ -18,9 +18,9 @@ type OAuthProvider struct {
Name nullable.Nullable[vo.String127] `json:"name"`
// oauth endpoints (user configurable)
AuthURL nullable.Nullable[vo.String512] `json:"authURL"`
TokenURL nullable.Nullable[vo.String512] `json:"tokenURL"`
Scopes nullable.Nullable[vo.String512] `json:"scopes"`
AuthURL nullable.Nullable[vo.String512] `json:"authURL"`
TokenURL nullable.Nullable[vo.String512] `json:"tokenURL"`
Scopes nullable.Nullable[vo.String2048] `json:"scopes"`
// user's oauth app credentials
ClientID nullable.Nullable[vo.String255] `json:"clientID"`

View File

@@ -219,7 +219,7 @@ func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider {
name := nullable.NewNullableWithValue(*vo.NewString127Must(row.Name))
authURL := nullable.NewNullableWithValue(*vo.NewString512Must(row.AuthURL))
tokenURL := nullable.NewNullableWithValue(*vo.NewString512Must(row.TokenURL))
scopes := nullable.NewNullableWithValue(*vo.NewString512Must(row.Scopes))
scopes := nullable.NewNullableWithValue(*vo.NewString2048Must(row.Scopes))
clientID := nullable.NewNullableWithValue(*vo.NewString255Must(row.ClientID))
clientSecret := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.ClientSecret))
accessToken := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.AccessToken))

View File

@@ -217,7 +217,7 @@ func (o *OAuthProvider) UpdateByID(
// clear all fields except name and id
provider.AuthURL = nullable.NewNullNullable[vo.String512]()
provider.TokenURL = nullable.NewNullNullable[vo.String512]()
provider.Scopes = nullable.NewNullNullable[vo.String512]()
provider.Scopes = nullable.NewNullNullable[vo.String2048]()
provider.ClientID = nullable.NewNullNullable[vo.String255]()
provider.ClientSecret = nullable.NewNullNullable[vo.OptionalString255]()
provider.AccessToken = nullable.NewNullNullable[vo.OptionalString1MB]()
@@ -603,11 +603,16 @@ func (o *OAuthProvider) getValidAccessTokenInternal(
data := url.Values{
"client_id": {clientID.String()},
"client_secret": {clientSecret},
"refresh_token": {refreshToken.String()},
"grant_type": {"refresh_token"},
}
// only include client_secret if it's not a placeholder (imported tokens use "n/a")
// public clients don't need/have client secrets
if clientSecret != "" && clientSecret != "n/a" {
data.Set("client_secret", clientSecret)
}
newTokens, err := o.requestTokens(tokenURL.String(), data)
if err != nil {
o.Logger.Errorw("failed to refresh tokens", "error", err)
@@ -697,19 +702,66 @@ func (o *OAuthProvider) ImportAuthorizedTokens(
// set default token url if not provided
token.SetDefaultTokenURL()
// convert expires_at from milliseconds to time
expiresAt := time.UnixMilli(token.ExpiresAt)
// generate name if empty
if token.Name == "" {
randomName, err := random.GenerateRandomURLBase64Encoded(16)
if err != nil {
o.Logger.Errorw("failed to generate random name for imported token", "error", err)
return nil, errs.Wrap(err)
}
token.Name = fmt.Sprintf("imported-%s", randomName)
}
// refresh token to get fresh access token and metadata
// note: don't send client_secret for imported tokens (public clients don't have/need it)
tokenURL := token.TokenURL
clientID := token.ClientID
data := url.Values{
"client_id": {clientID},
"refresh_token": {token.RefreshToken},
"grant_type": {"refresh_token"},
}
o.Logger.Debugw("refreshing token during import", "name", token.Name)
newTokens, err := o.requestTokens(tokenURL, data)
if err != nil {
o.Logger.Errorw("failed to refresh token during import", "error", err, "name", token.Name)
return nil, fmt.Errorf("failed to refresh token for '%s': %w", token.Name, err)
}
// use refreshed access token
accessToken := newTokens.AccessToken
// some providers return new refresh token, some don't
refreshToken := newTokens.RefreshToken
if refreshToken == "" {
// keep the original refresh token
refreshToken = token.RefreshToken
}
// calculate expiry from refresh response
expiresAt := time.Now().Add(time.Duration(newTokens.ExpiresIn) * time.Second)
// use scope from refresh response if available, otherwise use provided scope
// if both are empty, use placeholder to satisfy validation
scope := newTokens.Scope
if scope == "" {
scope = token.Scope
}
if scope == "" {
scope = "offline_access" // placeholder scope if none provided
}
// create provider with imported flag
provider := &model.OAuthProvider{
Name: nullable.NewNullableWithValue(*vo.NewString127Must(token.Name)),
AuthURL: nullable.NewNullableWithValue(*vo.NewString512Must("n/a")), // placeholder for imported
TokenURL: nullable.NewNullableWithValue(*vo.NewString512Must(token.TokenURL)),
Scopes: nullable.NewNullableWithValue(*vo.NewString512Must(token.Scope)),
Scopes: nullable.NewNullableWithValue(*vo.NewString2048Must(scope)),
ClientID: nullable.NewNullableWithValue(*vo.NewString255Must(token.ClientID)),
ClientSecret: nullable.NewNullableWithValue(*vo.NewOptionalString255Must("n/a")), // placeholder for imported
AccessToken: nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(token.AccessToken)),
RefreshToken: nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(token.RefreshToken)),
AccessToken: nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(accessToken)),
RefreshToken: nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(refreshToken)),
TokenExpiresAt: &expiresAt,
AuthorizedEmail: nullable.NewNullableWithValue(*vo.NewOptionalString255Must(token.User)),
AuthorizedAt: ptrTime(time.Now()),

View File

@@ -552,6 +552,113 @@ func (s OptionalString1024) String() string {
return s.inner
}
// String2048 is a trimmed string with a min of 1 and a max of 2048
type String2048 struct {
inner string
}
// NewString2048 creates a new long string
func NewString2048(s string) (*String2048, error) {
s = strings.TrimSpace(s)
err := validate.ErrorIfStringNotbetweenOrEqualTo(s, 1, 2048)
if err != nil {
return nil, errs.Wrap(err)
}
return &String2048{
inner: s,
}, nil
}
// NewString2048Must creates a new long string and panics if it fails
func NewString2048Must(s string) *String2048 {
a, err := NewString2048(s)
if err != nil {
panic(err)
}
return a
}
// MarshalJSON implements the json.Marshaler interface
func (s String2048) MarshalJSON() ([]byte, error) {
return json.Marshal(s.inner)
}
// UnmarshalJSON unmarshals the json into a string
func (s *String2048) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
ss, err := NewString2048(str)
if err != nil {
return unwrapError(err)
}
s.inner = ss.inner
return nil
}
// String returns the string representation of the long string
func (s String2048) String() string {
return s.inner
}
// OptionalString2048 is a trimmed string with a min of 0 and a max of 2048
type OptionalString2048 struct {
inner string
}
// NewEmptyOptionalString2048 creates a new empty string
func NewEmptyOptionalString2048() *OptionalString2048 {
return &OptionalString2048{
inner: "",
}
}
// NewOptionalString2048 creates a new long string
func NewOptionalString2048(s string) (*OptionalString2048, error) {
s = strings.TrimSpace(s)
err := validate.ErrorIfStringNotbetweenOrEqualTo(s, 0, 2048)
if err != nil {
return nil, errs.Wrap(err)
}
return &OptionalString2048{
inner: s,
}, nil
}
// NewOptionalString2048Must creates a new long string and panics if it fails
func NewOptionalString2048Must(s string) *OptionalString2048 {
a, err := NewOptionalString2048(s)
if err != nil {
panic(err)
}
return a
}
// MarshalJSON implements the json.Marshaler interface
func (s OptionalString2048) MarshalJSON() ([]byte, error) {
return json.Marshal(s.inner)
}
// UnmarshalJSON unmarshals the json into a string
func (s *OptionalString2048) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
ss, err := NewOptionalString2048(str)
if err != nil {
return unwrapError(err)
}
s.inner = ss.inner
return nil
}
// String returns the string representation of the long string
func (s OptionalString2048) String() string {
return s.inner
}
// String1MB is a trimmed string with a min of 1 and a max of 1000000
type String1MB struct {
inner string