From bbc49deedd8e80fb3a7f2a188e71fec80f3e8d68 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Sat, 13 Dec 2025 21:39:08 +0100 Subject: [PATCH] add import authorized oauth Signed-off-by: Ronni Skansing --- backend/app/administration.go | 4 + backend/controller/oauthProvider.go | 58 +++ backend/database/oauthProvider.go | 4 + backend/model/importAuthorizedToken.go | 50 +++ backend/model/oauthProvider.go | 11 + backend/repository/oauthProvider.go | 2 + backend/service/oauthProvider.go | 192 ++++++++ examples/oauth-token-import.md | 183 ++++++++ frontend/src/lib/api/api.js | 20 + .../src/routes/oauth-provider/+page.svelte | 409 +++++++++++++++--- 10 files changed, 861 insertions(+), 72 deletions(-) create mode 100644 backend/model/importAuthorizedToken.go create mode 100644 examples/oauth-token-import.md diff --git a/backend/app/administration.go b/backend/app/administration.go index 5fc3f6e..ca1678f 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -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). diff --git a/backend/controller/oauthProvider.go b/backend/controller/oauthProvider.go index 1da770a..f9250d4 100644 --- a/backend/controller/oauthProvider.go +++ b/backend/controller/oauthProvider.go @@ -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) +} diff --git a/backend/database/oauthProvider.go b/backend/database/oauthProvider.go index 289d86a..33c5e0b 100644 --- a/backend/database/oauthProvider.go +++ b/backend/database/oauthProvider.go @@ -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;"` diff --git a/backend/model/importAuthorizedToken.go b/backend/model/importAuthorizedToken.go new file mode 100644 index 0000000..22777f7 --- /dev/null +++ b/backend/model/importAuthorizedToken.go @@ -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" + } +} diff --git a/backend/model/oauthProvider.go b/backend/model/oauthProvider.go index 55be355..609f3c1 100644 --- a/backend/model/oauthProvider.go +++ b/backend/model/oauthProvider.go @@ -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 } diff --git a/backend/repository/oauthProvider.go b/backend/repository/oauthProvider.go index e40b6b3..04eb87a 100644 --- a/backend/repository/oauthProvider.go +++ b/backend/repository/oauthProvider.go @@ -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, } } diff --git a/backend/service/oauthProvider.go b/backend/service/oauthProvider.go index 7f26fb9..45f5948 100644 --- a/backend/service/oauthProvider.go +++ b/backend/service/oauthProvider.go @@ -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) diff --git a/examples/oauth-token-import.md b/examples/oauth-token-import.md new file mode 100644 index 0000000..6b492fa --- /dev/null +++ b/examples/oauth-token-import.md @@ -0,0 +1,183 @@ +# OAuth Token Import + +This guide explains how to import existing OAuth access and refresh tokens into PhishingClub. + +## Overview + +You may want to import existing OAuth tokens if you: +- Already have valid tokens from a previous OAuth authorization +- Want to migrate tokens from another system +- Have tokens generated through a different OAuth flow +- Need to use tokens that were authorized outside of PhishingClub + +## Requirements + +Before importing tokens, you need: +1. Valid access and refresh tokens from an OAuth provider +2. The token expiration timestamp (in milliseconds) +3. The client ID associated with the tokens +4. The token URL for refreshing tokens + +## Import Format + +Tokens must be provided as a JSON array. Each token object should have the following fields: + +```json +[ + { + "access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6...", + "refresh_token": "1.AXkAwC9YcwqenkWrp4TriW...", + "client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", + "expires_at": 1765657989704, + "name": "user@example.com (Microsoft Teams)", + "user": "user@example.com", + "scope": "https://graph.microsoft.com/.default offline_access", + "token_url": "https://login.microsoftonline.com/73582fc0-9e0a-459e-aba7-84eb896f9a3f/oauth2/v2.0/token", + "created_at": 1765634409156 + } +] +``` + +### Field Descriptions + +- **access_token** (required): The OAuth access token (typically a JWT) +- **refresh_token** (required): The OAuth refresh token used to get new access tokens +- **client_id** (required): The OAuth application's client ID +- **expires_at** (required): Unix timestamp in milliseconds when the access token expires +- **name** (required): A display name for this token (e.g., "user@example.com (Microsoft Teams)") +- **user** (required): The email or username of the authorized account +- **scope** (required): Space-separated list of OAuth scopes +- **token_url** (optional): The token endpoint URL. Defaults to Microsoft's token endpoint if not provided +- **created_at** (optional): Unix timestamp in milliseconds when the token was created + +## Importing Tokens via UI + +1. Navigate to the **OAuth** page in PhishingClub +2. Click the **Import Authorized Token** button +3. Paste your JSON array into the text field +4. Click **Submit** + +The system will validate and import all tokens in the array. Each token becomes a separate OAuth provider entry. + +## Importing Tokens via API + +You can also import tokens programmatically using the API: + +```bash +POST /api/v1/oauth-provider/import-tokens +Content-Type: application/json + +[ + { + "access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6...", + "refresh_token": "1.AXkAwC9YcwqenkWrp4TriW...", + "client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", + "expires_at": 1765657989704, + "name": "user@example.com (Microsoft Teams)", + "user": "user@example.com", + "scope": "https://graph.microsoft.com/.default offline_access" + } +] +``` + +Response: +```json +{ + "success": true, + "data": { + "ids": ["550e8400-e29b-41d4-a716-446655440000"], + "count": 1 + } +} +``` + +## Exporting Tokens + +You can export tokens from PhishingClub to the same format used for importing: + +1. Navigate to the **OAuth** page +2. Find the provider you want to export +3. Click the **ellipsis menu (⋮)** on the provider row +4. Select **Export Token** + +The token will be downloaded as a JSON file that can be imported elsewhere. + +### Export via API + +```bash +GET /api/v1/oauth-provider/{id}/export-tokens +``` + +## Imported Provider Restrictions + +OAuth providers created via import have some restrictions compared to regular providers: + +1. **Cannot be authorized or re-authorized** - They use pre-authorized tokens and cannot go through the OAuth flow +2. **Limited editing** - Only the name field can be edited. Client credentials and URLs cannot be changed +3. **No copying** - Imported providers cannot be copied +4. **Token refresh only** - They can only refresh their tokens using the refresh token, not obtain new ones via authorization + +## Microsoft 365 / Teams Example + +If you have Microsoft Teams or Office 365 OAuth tokens, they typically look like this: + +```json +[ + { + "access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6ImVCUV9XLUV5d2gtSlpqbXFuWDlUTGF2ZkNIR09kV2U4RFBJM1k5a3FibGciLCJhbGciOiJSUzI1NiIsIng1dCI6InJ0c0ZULWItN0x1WTdEVlllU05LY0lKN1ZuYyIsImtpZCI6InJ0c0ZULWItN0x1WTdEVlllU05LY0lKN1ZuYyJ9...", + "refresh_token": "1.AXkAwC9YcwqenkWrp4TriW-aP3iO7B_kvK9KqxtUUcw4cmR5AKd5AA.BQABAwEAAAADAOz_BQD0_zaTKAHXsCeupQqQPHKcMtLP8K45KxFiwFgAFG5-NCiSMh30e3jgmHbuuFBYk8qWOaMLqmrh_jfA5biRz6pm7w6zmcD4HbpDCUeQ2eZ0UHAl4aeZbp5FYlSrBgozbdLkgDnSXjKOSeHTVprpbOpe94rzqapKwLvUNjHPQvSiRYyOlh94chh-DTEWSYGUK1EA0bShHa51ZfLZOIeLkeDzqieuSt7b4eqBVvkTLArtAHceN0V9rbLTfMAg18usGY6vdZEwOWwAjayuT-xZPSKdTuqeN6CZ1BHKBj7fhmy48jiXyQolgAL6eXMUnjPC_FsUplJn3guYYXmzTh3B6PHJn6EQjuKWGR9Iaaw4TGB0qmwfJJBfsFNv25EKUR8ragrHE-tTvp9fDuRzTMgW9-_kmngdz95W_ob1RaxN6gsLpZ1O1y75dJkFnoQGLy05YdTAQzo9fdddwHR2NMbe7ovXw440hPqb_ValzBY1ovsMSrr2QSFM1VZDdkWKDyj9JBgzd3XVTWLgcUXpxnou_bM2ZHs2EiRQx9FiFCscTAHg7iFjcMIzA1tFUTunKYtcHN3m6-XGiPUQp2g3Zwu8fEnYo_dG10Ci3uw2PkxmsrVHAT3btlY9QnyGzgUQzfR_Cg9mEmXr476NQE6_NqlBPjZRho2klvolBgAthXtyZPKM1vhL-ei7AcMico9_06DBh-g1uavfQ9LtBLG_RCXfKqU2bTsN0KFp4AhTd49jvGLVPw_bFcQ1DzZHLYiQLv5lZqO6ATZiKJHY9FO3Twpnj7dKxqaxytXlBQ3lmxB5MxjRJumK8lvtlF21-MXn5HYymliU1okLsgHIs-lN-NOVvEMQ2tpt-pJ_XEp8l6baMzjcdILaJ9HkiNPhGoWZ3qkv7k_DKpybkZLX6On9KamfaBP-CKcopBbqvHomuZUItm_6MAdMJBuMFypszop4v_rXq1PC3XYlbDcfwiyHoQen79CgX6xap_31UqeYCaTvazQwFlho-Y3CheTfbDRjRJqfxSPmQDZkZugwRlIICDRlId8GogPqH4a_DCv5N3pbmu-lTNJ0YibCYbEanQxyot_UTQGWCRulUIQ28o8Y5wMn9UoBAzD9opT4g1XW3WnAogU4mdOwO40hhXMraW4Cjd5JQOvj-HUmxlwCHP-PiyhHn9w_WDlqdkigJerw3t12L8wZwC77yipee8JGbZFiIoNYW462wtpkUBkqSfsI2D5O-MgeriY5kYHbCz5e-I5g4pwRj54_mwQUkc61OHkf0rXyiVhH8zsmrCvkWov6gPmf9259_QEZ", + "client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", + "expires_at": 1765657989704, + "name": "ronni@365.skansing.dk (Microsoft Teams)", + "user": "ronni@365.skansing.dk", + "scope": "https://graph.microsoft.com/.default offline_access" + } +] +``` + +### Microsoft OAuth Endpoints + +For Microsoft 365/Teams OAuth: + +**Authorization URL:** +``` +https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize +``` + +**Token URL:** +``` +https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token +``` + +Replace `{tenant-id}` with your Azure AD tenant ID. + +## Common Issues + +### Invalid Token Format +Ensure your JSON is properly formatted and all required fields are present. + +### Expired Tokens +The refresh token is invalid or has expired. You need to perform a new OAuth authorization flow. + +### Token Refresh Failures +If token refresh fails, check that: +- The token URL is correct +- The client ID matches the one used to obtain the tokens +- The refresh token hasn't been revoked + +## Security Best Practices + +1. **Secure Storage**: Imported tokens are stored encrypted in the database, just like regular OAuth tokens. +2. **Access Control**: Only users with global permissions can import and export tokens. +3. **Token Rotation**: Tokens will be automatically refreshed when they expire using the refresh token. +4. **Audit Trail**: All import and export operations are logged in the audit log. + +## Troubleshooting + +If you encounter issues importing tokens: + +1. Verify the JSON format is correct (use a JSON validator) +2. Check that all required fields are present +3. Ensure the expires_at timestamp is in milliseconds, not seconds +4. Verify the token_url is accessible and correct +5. Check the application logs for detailed error messages \ No newline at end of file diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index ef55303..9d0e19d 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -1905,6 +1905,26 @@ export class API { */ removeAuthorization: async (id) => { return await postJSON(this.getPath(`/oauth-provider/${id}/remove-authorization`), {}); + }, + + /** + * Import authorized OAuth tokens. + * + * @param {Array<{access_token: string, refresh_token: string, client_id: string, expires_at: number, name: string, user: string, scope: string, token_url?: string, created_at?: number}>} tokens + * @returns {Promise} + */ + importTokens: async (tokens) => { + return await postJSON(this.getPath('/oauth-provider/import-tokens'), tokens); + }, + + /** + * Export OAuth tokens for a provider. + * + * @param {string} id + * @returns {Promise} + */ + exportTokens: async (id) => { + return await getJSON(this.getPath(`/oauth-provider/${id}/export-tokens`)); } }; diff --git a/frontend/src/routes/oauth-provider/+page.svelte b/frontend/src/routes/oauth-provider/+page.svelte index 9c0b44b..6fbc81e 100644 --- a/frontend/src/routes/oauth-provider/+page.svelte +++ b/frontend/src/routes/oauth-provider/+page.svelte @@ -68,6 +68,12 @@ id: null, name: null }; + let isImportModalVisible = false; + let importTokensText = ''; + let importFormError = ''; + let isExportModalVisible = false; + let exportTokensText = ''; + let exportTokenExpiry = ''; $: { modalText = getModalText('OAuth', modalMode); @@ -279,6 +285,92 @@ isModalVisible = true; }; + const openImportModal = () => { + importTokensText = ''; + importFormError = ''; + isImportModalVisible = true; + }; + + const closeImportModal = () => { + isImportModalVisible = false; + importTokensText = ''; + importFormError = ''; + }; + + const onClickImport = async () => { + importFormError = ''; + try { + isSubmitting = true; + const tokens = JSON.parse(importTokensText); + if (!Array.isArray(tokens)) { + importFormError = 'Input must be an array of tokens'; + return; + } + const res = await api.oauthProvider.importTokens(tokens); + if (!res.success) { + importFormError = res.error; + return; + } + addToast(`Imported ${res.data.count} OAuth token(s)`, 'Success'); + closeImportModal(); + refreshProviders(); + } catch (err) { + if (err instanceof SyntaxError) { + importFormError = 'Invalid JSON format'; + } else { + importFormError = 'Failed to import tokens'; + console.error('failed to import tokens:', err); + } + } finally { + isSubmitting = false; + } + }; + + const onClickExport = async (id) => { + try { + showIsLoading(); + const res = await api.oauthProvider.exportTokens(id); + if (res.success) { + // format as array for consistency with import format + exportTokensText = JSON.stringify([res.data], null, 2); + + // calculate expiry date + if (res.data.expires_at) { + const expiryDate = new Date(res.data.expires_at); + exportTokenExpiry = expiryDate.toLocaleString(); + } else { + exportTokenExpiry = 'Unknown'; + } + + isExportModalVisible = true; + } else { + throw res.error; + } + } catch (e) { + addToast('Failed to export OAuth token', 'Error'); + console.error('failed to export oauth token', e); + } finally { + hideIsLoading(); + } + }; + + const closeExportModal = () => { + isExportModalVisible = false; + exportTokensText = ''; + exportTokenExpiry = ''; + }; + + const onClickCopyExport = () => { + navigator.clipboard + .writeText(exportTokensText) + .then(() => { + addToast('Copied to clipboard', 'Success'); + }) + .catch(() => { + addToast('Failed to copy to clipboard', 'Error'); + }); + }; + const openUpdateModal = async (id) => { modalMode = 'update'; formError = ''; @@ -294,7 +386,8 @@ authURL: provider.authURL, tokenURL: provider.tokenURL, scopes: provider.scopes, - companyID: provider.companyID + companyID: provider.companyID, + isImported: provider.isImported }; isModalVisible = true; }; @@ -385,7 +478,10 @@
OAuth - New OAuth +
+ New OAuth + Import Token +
- openCopyModal(provider.id)} - {...globalButtonDisabledAttributes(provider, contextCompanyID)} - /> + {#if !provider.isImported} + openCopyModal(provider.id)} + {...globalButtonDisabledAttributes(provider, contextCompanyID)} + /> + {/if} openUpdateModal(provider.id)} {...globalButtonDisabledAttributes(provider, contextCompanyID)} /> - {#if !provider.isAuthorized} + {#if provider.isAuthorized} - {:else} - - {/if} + {#if !provider.isImported} + {#if !provider.isAuthorized} + + {:else} + + + {/if} + {/if} openDeleteAlert(provider)} {...globalButtonDisabledAttributes(provider, contextCompanyID)} @@ -476,53 +585,59 @@ > Name - - Client ID - - - Client Secret - - - Authorization URL - - - Token URL - - Scopes + {#if modalMode === 'update' && formValues.isImported} +

+ This is an imported provider. Only the name can be edited. +

+ {:else} + + Client ID + + + Client Secret + + + Authorization URL + + + Token URL + + Scopes + {/if} @@ -531,6 +646,156 @@ + +
+
+

+ Import a pre-authorized OAuth token that was obtained outside of PhishingClub. +

+
+ +
+ +
+ +
+ + +
+
+ + + + + + + + + + +
+ + +
+ +
+

Token Information

+

+ Expires at: + {exportTokenExpiry} +

+
+ + +
+
+ +