add import authorized oauth

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-12-13 21:39:08 +01:00
parent 5418d37005
commit bbc49deedd
10 changed files with 861 additions and 72 deletions
+4
View File
@@ -194,6 +194,8 @@ const (
ROUTE_V1_OAUTH_PROVIDER_REMOVE_AUTH = "/api/v1/oauth-provider/:id/remove-authorization"
ROUTE_V1_OAUTH_AUTHORIZE = "/api/v1/oauth-authorize/:id"
ROUTE_V1_OAUTH_CALLBACK = "/api/v1/oauth-callback"
ROUTE_V1_OAUTH_IMPORT_TOKENS = "/api/v1/oauth-provider/import-tokens"
ROUTE_V1_OAUTH_EXPORT_TOKENS = "/api/v1/oauth-provider/:id/export-tokens"
// license
ROUTE_V1_LICENSE = "/api/v1/license"
// version
@@ -377,6 +379,8 @@ func setupRoutes(
POST(ROUTE_V1_OAUTH_PROVIDER_REMOVE_AUTH, middleware.SessionHandler, controllers.OAuthProvider.RemoveAuthorization).
GET(ROUTE_V1_OAUTH_AUTHORIZE, middleware.SessionHandler, controllers.OAuthProvider.GetAuthorizationURL).
GET(ROUTE_V1_OAUTH_CALLBACK, controllers.OAuthProvider.HandleCallback).
POST(ROUTE_V1_OAUTH_IMPORT_TOKENS, middleware.SessionHandler, controllers.OAuthProvider.ImportAuthorizedTokens).
GET(ROUTE_V1_OAUTH_EXPORT_TOKENS, middleware.SessionHandler, controllers.OAuthProvider.ExportAuthorizedTokens).
// emails
GET(ROUTE_V1_EMAIL, middleware.SessionHandler, controllers.Email.GetAll).
GET(ROUTE_V1_EMAIL_OVERVIEW, middleware.SessionHandler, controllers.Email.GetOverviews).
+58
View File
@@ -372,3 +372,61 @@ func (c *OAuthProvider) renderCallbackPage(g *gin.Context, success bool, errorCo
g.Header("Content-Type", "text/html; charset=utf-8")
g.String(http.StatusOK, html)
}
// ImportAuthorizedTokens imports pre-authorized oauth tokens
func (c *OAuthProvider) ImportAuthorizedTokens(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req []model.ImportAuthorizedToken
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// import tokens
ids, err := c.OAuthProviderService.ImportAuthorizedTokens(
g.Request.Context(),
session,
req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
// convert ids to strings
idStrings := make([]string, len(ids))
for i, id := range ids {
idStrings[i] = id.String()
}
c.Response.OK(g, gin.H{
"ids": idStrings,
"count": len(ids),
})
}
// ExportAuthorizedTokens exports oauth tokens in the import format
func (c *OAuthProvider) ExportAuthorizedTokens(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse id
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// export tokens
exported, err := c.OAuthProviderService.ExportAuthorizedTokens(
g.Request.Context(),
session,
*id,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, exported)
}
+4
View File
@@ -40,6 +40,10 @@ type OAuthProvider struct {
// status
IsAuthorized bool `gorm:"not null;default:false;"`
// indicates if this provider was created via import (with pre-authorized tokens)
// imported providers cannot be authorized/reauthorized via oauth flow
IsImported bool `gorm:"not null;default:false;"`
// can belong-to
CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_oauth_providers_unique_name_and_company_id;"`
Company *Company `gorm:"foreignkey:CompanyID;"`
+50
View File
@@ -0,0 +1,50 @@
package model
import (
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/validate"
)
// ImportAuthorizedToken represents an imported oauth token
type ImportAuthorizedToken struct {
AccessToken string `json:"access_token"`
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"`
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
}
// SetDefaultTokenURL sets the default token url if not provided
func (i *ImportAuthorizedToken) SetDefaultTokenURL() {
if i.TokenURL == "" {
// default to microsoft token url (most common use case)
i.TokenURL = "https://login.microsoftonline.com/73582fc0-9e0a-459e-aba7-84eb896f9a3f/oauth2/v2.0/token"
}
}
+11
View File
@@ -38,6 +38,10 @@ type OAuthProvider struct {
// status
IsAuthorized nullable.Nullable[bool] `json:"isAuthorized"` // whether oauth flow completed
// indicates if this provider was created via import (with pre-authorized tokens)
// imported providers cannot be authorized/reauthorized via oauth flow
IsImported nullable.Nullable[bool] `json:"isImported"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
Company *Company `json:"company"`
}
@@ -175,5 +179,12 @@ func (o *OAuthProvider) ToDBMap() map[string]any {
}
}
if o.IsImported.IsSpecified() {
m["is_imported"] = nil
if isImported, err := o.IsImported.Get(); err == nil {
m["is_imported"] = isImported
}
}
return m
}
+2
View File
@@ -226,6 +226,7 @@ func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider {
refreshToken := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.RefreshToken))
authorizedEmail := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.AuthorizedEmail))
isAuthorized := nullable.NewNullableWithValue(row.IsAuthorized)
isImported := nullable.NewNullableWithValue(row.IsImported)
return &model.OAuthProvider{
ID: id,
@@ -244,6 +245,7 @@ func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider {
AuthorizedEmail: authorizedEmail,
AuthorizedAt: row.AuthorizedAt,
IsAuthorized: isAuthorized,
IsImported: isImported,
Company: nil,
}
}
+192
View File
@@ -212,6 +212,20 @@ func (o *OAuthProvider) UpdateByID(
return errs.Wrap(err)
}
// for imported providers, only allow name updates
if existing.IsImported.MustGet() {
// 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.ClientID = nullable.NewNullNullable[vo.String255]()
provider.ClientSecret = nullable.NewNullNullable[vo.OptionalString255]()
provider.AccessToken = nullable.NewNullNullable[vo.OptionalString1MB]()
provider.RefreshToken = nullable.NewNullNullable[vo.OptionalString1MB]()
provider.IsAuthorized = nullable.NewNullNullable[bool]()
provider.IsImported = nullable.NewNullNullable[bool]()
}
var companyID *uuid.UUID
if cid, err := existing.CompanyID.Get(); err == nil {
companyID = &cid
@@ -366,6 +380,11 @@ func (o *OAuthProvider) GetAuthorizationURL(
return "", errs.Wrap(err)
}
// prevent authorization on imported providers
if provider.IsImported.MustGet() {
return "", errors.New("cannot authorize imported providers - they use pre-authorized tokens")
}
// generate cryptographically random state token (32 bytes base64-encoded)
stateToken, err := random.GenerateRandomURLBase64Encoded(32)
if err != nil {
@@ -643,6 +662,179 @@ func (o *OAuthProvider) requestTokens(tokenURL string, data url.Values) (*TokenR
return &tokens, nil
}
// ImportAuthorizedTokens imports pre-authorized oauth tokens
func (o *OAuthProvider) ImportAuthorizedTokens(
ctx context.Context,
session *model.Session,
tokens []model.ImportAuthorizedToken,
) ([]uuid.UUID, error) {
ae := NewAuditEvent("OAuthProvider.ImportAuthorizedTokens", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// validate input
if len(tokens) == 0 {
return nil, errors.New("no tokens provided")
}
var ids []uuid.UUID
for _, token := range tokens {
// validate token
if err := token.Validate(); err != nil {
return nil, err
}
// set default token url if not provided
token.SetDefaultTokenURL()
// convert expires_at from milliseconds to time
expiresAt := time.UnixMilli(token.ExpiresAt)
// 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)),
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)),
TokenExpiresAt: &expiresAt,
AuthorizedEmail: nullable.NewNullableWithValue(*vo.NewOptionalString255Must(token.User)),
AuthorizedAt: ptrTime(time.Now()),
IsAuthorized: nullable.NewNullableWithValue(true),
IsImported: nullable.NewNullableWithValue(true),
CompanyID: nullable.NewNullNullable[uuid.UUID](),
}
// check uniqueness
isOK, err := repository.CheckNameIsUnique(
ctx,
o.OAuthProviderRepository.DB,
"oauth_providers",
token.Name,
nil,
nil,
)
if err != nil {
o.Logger.Errorw("failed to check oauth provider uniqueness", "error", err)
return nil, errs.Wrap(err)
}
if !isOK {
o.Logger.Debugw("oauth provider name is already used", "name", token.Name)
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
// save
id, err := o.OAuthProviderRepository.Insert(ctx, provider)
if err != nil {
o.Logger.Errorw("failed to insert imported oauth provider", "error", err)
return nil, errs.Wrap(err)
}
ids = append(ids, *id)
}
ae.Details["count"] = len(ids)
o.AuditLogAuthorized(ae)
return ids, nil
}
// ExportAuthorizedTokens exports oauth tokens in the import format
func (o *OAuthProvider) ExportAuthorizedTokens(
ctx context.Context,
session *model.Session,
providerID uuid.UUID,
) (*model.ImportAuthorizedToken, error) {
ae := NewAuditEvent("OAuthProvider.ExportAuthorizedTokens", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get provider
provider, err := o.OAuthProviderRepository.GetByID(ctx, providerID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.Wrap(err)
}
o.Logger.Errorw("failed to get oauth provider", "error", err)
return nil, errs.Wrap(err)
}
// check if provider is authorized
if !provider.IsAuthorized.MustGet() {
return nil, errors.New("provider is not authorized")
}
// extract tokens
accessToken := ""
if at, err := provider.AccessToken.Get(); err == nil {
accessToken = at.String()
}
refreshToken := ""
if rt, err := provider.RefreshToken.Get(); err == nil {
refreshToken = rt.String()
}
authorizedEmail := ""
if ae, err := provider.AuthorizedEmail.Get(); err == nil {
authorizedEmail = ae.String()
}
var expiresAt int64
if provider.TokenExpiresAt != nil {
expiresAt = provider.TokenExpiresAt.UnixMilli()
}
var createdAt int64
if provider.CreatedAt != nil {
createdAt = provider.CreatedAt.UnixMilli()
}
exported := &model.ImportAuthorizedToken{
AccessToken: accessToken,
RefreshToken: refreshToken,
ClientID: provider.ClientID.MustGet().String(),
ExpiresAt: expiresAt,
Name: provider.Name.MustGet().String(),
User: authorizedEmail,
Scope: provider.Scopes.MustGet().String(),
TokenURL: provider.TokenURL.MustGet().String(),
CreatedAt: createdAt,
}
ae.Details["id"] = providerID.String()
o.AuditLogAuthorized(ae)
return exported, nil
}
// ptrTime returns a pointer to a time.Time
func ptrTime(t time.Time) *time.Time {
return &t
}
/* @TODO the logic is here, but i dont think we really need to implement it
// CleanupExpiredStates removes expired oauth state tokens from database
// should be called periodically (e.g., daily)