From 428941ee77f5054656624299dbd54d586390266a Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Sun, 14 Dec 2025 20:43:36 +0100 Subject: [PATCH] only require needed for oauth import Signed-off-by: Ronni Skansing --- backend/database/oauthProvider.go | 2 +- backend/model/importAuthorizedToken.go | 22 +--- backend/model/oauthProvider.go | 6 +- backend/repository/oauthProvider.go | 2 +- backend/service/oauthProvider.go | 66 +++++++++-- backend/vo/generic.go | 107 ++++++++++++++++++ .../src/routes/oauth-provider/+page.svelte | 64 ++++++++--- 7 files changed, 225 insertions(+), 44 deletions(-) diff --git a/backend/database/oauthProvider.go b/backend/database/oauthProvider.go index 33c5e0b..9857ec0 100644 --- a/backend/database/oauthProvider.go +++ b/backend/database/oauthProvider.go @@ -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);"` diff --git a/backend/model/importAuthorizedToken.go b/backend/model/importAuthorizedToken.go index 22777f7..863f567 100644 --- a/backend/model/importAuthorizedToken.go +++ b/backend/model/importAuthorizedToken.go @@ -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 } diff --git a/backend/model/oauthProvider.go b/backend/model/oauthProvider.go index 609f3c1..f74f07d 100644 --- a/backend/model/oauthProvider.go +++ b/backend/model/oauthProvider.go @@ -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"` diff --git a/backend/repository/oauthProvider.go b/backend/repository/oauthProvider.go index 04eb87a..0c0a995 100644 --- a/backend/repository/oauthProvider.go +++ b/backend/repository/oauthProvider.go @@ -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)) diff --git a/backend/service/oauthProvider.go b/backend/service/oauthProvider.go index 45f5948..301283e 100644 --- a/backend/service/oauthProvider.go +++ b/backend/service/oauthProvider.go @@ -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()), diff --git a/backend/vo/generic.go b/backend/vo/generic.go index 6411dfd..330779c 100644 --- a/backend/vo/generic.go +++ b/backend/vo/generic.go @@ -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 diff --git a/frontend/src/routes/oauth-provider/+page.svelte b/frontend/src/routes/oauth-provider/+page.svelte index 6fbc81e..2365003 100644 --- a/frontend/src/routes/oauth-provider/+page.svelte +++ b/frontend/src/routes/oauth-provider/+page.svelte @@ -297,6 +297,20 @@ importFormError = ''; }; + const onSetImportFile = (event) => { + // read file from event + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + importTokensText = /** @type {string} */ (e.target.result); + }; + reader.readAsText(file); + // reset field + event.target.value = ''; + }; + const onClickImport = async () => { importFormError = ''; try { @@ -655,10 +669,36 @@

- Import a pre-authorized OAuth token that was obtained outside of PhishingClub. + Import a pre-authorized OAuth token. Only refresh_token and + client_id are required. The system will automatically refresh to get a valid + access token and populate metadata.

+
+ + +
+