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

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).

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)
}

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;"`

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"
}
}

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
}

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,
}
}

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)

View File

@@ -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

View File

@@ -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<ApiResponse>}
*/
importTokens: async (tokens) => {
return await postJSON(this.getPath('/oauth-provider/import-tokens'), tokens);
},
/**
* Export OAuth tokens for a provider.
*
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
exportTokens: async (id) => {
return await getJSON(this.getPath(`/oauth-provider/${id}/export-tokens`));
}
};

View File

@@ -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 @@
<HeadTitle title="OAuth" />
<main>
<Headline>OAuth</Headline>
<BigButton on:click={openCreateModal}>New OAuth</BigButton>
<div class="flex gap-2 mb-4">
<BigButton on:click={openCreateModal}>New OAuth</BigButton>
<BigButton on:click={openImportModal}>Import Token</BigButton>
</div>
<Table
columns={['Name', 'Status']}
sortable={['name', 'is_authorized']}
@@ -420,39 +516,52 @@
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableCopyButton
title="Copy"
on:click={() => openCopyModal(provider.id)}
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
/>
{#if !provider.isImported}
<TableCopyButton
title="Copy"
on:click={() => openCopyModal(provider.id)}
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
/>
{/if}
<TableUpdateButton
on:click={() => openUpdateModal(provider.id)}
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
/>
{#if !provider.isAuthorized}
{#if provider.isAuthorized}
<button
type="button"
on:click={() => onClickAuthorize(provider.id)}
on:click={() => onClickExport(provider.id)}
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
>
<p class="ml-2 text-left">Authorize</p>
</button>
{:else}
<button
type="button"
on:click={() => onClickAuthorize(provider.id)}
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
>
<p class="ml-2 text-left">Re-authorize</p>
</button>
<button
type="button"
on:click={() => openRemoveAuthAlert(provider)}
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
>
<p class="ml-2 text-left">Remove Authorization</p>
<p class="ml-2 text-left">Read Token</p>
</button>
{/if}
{#if !provider.isImported}
{#if !provider.isAuthorized}
<button
type="button"
on:click={() => onClickAuthorize(provider.id)}
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
>
<p class="ml-2 text-left">Authorize</p>
</button>
{:else}
<button
type="button"
on:click={() => onClickAuthorize(provider.id)}
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
>
<p class="ml-2 text-left">Re-authorize</p>
</button>
<button
type="button"
on:click={() => openRemoveAuthAlert(provider)}
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
>
<p class="ml-2 text-left">Remove Authorization</p>
</button>
{/if}
{/if}
<TableDeleteButton
on:click={() => openDeleteAlert(provider)}
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
@@ -476,53 +585,59 @@
>
Name
</TextField>
<TextField
required
minLength={1}
maxLength={255}
bind:value={formValues.clientID}
placeholder="your-client-id"
>
Client ID
</TextField>
<PasswordField
required={modalMode === 'create' || modalMode === 'copy'}
minLength={modalMode === 'update' ? 0 : 1}
maxLength={255}
bind:value={formValues.clientSecret}
placeholder={modalMode === 'update'
? 'Leave empty to keep existing secret'
: 'your-client-secret'}
>
Client Secret
</PasswordField>
<TextField
required
minLength={1}
maxLength={512}
bind:value={formValues.authURL}
placeholder="https://example.com/oauth2/v2/auth"
>
Authorization URL
</TextField>
<TextField
required
minLength={1}
maxLength={512}
bind:value={formValues.tokenURL}
placeholder="https://example.com/oauth2/token"
>
Token URL
</TextField>
<TextareaField
required
minLength={1}
maxLength={512}
bind:value={formValues.scopes}
placeholder="https://example.com/auth/mail.send"
height="small"
toolTipText="Space-separated list of OAuth scopes">Scopes</TextareaField
>
{#if modalMode === 'update' && formValues.isImported}
<p class="text-sm text-gray-600 dark:text-gray-400 italic">
This is an imported provider. Only the name can be edited.
</p>
{:else}
<TextField
required
minLength={1}
maxLength={255}
bind:value={formValues.clientID}
placeholder="your-client-id"
>
Client ID
</TextField>
<PasswordField
required={modalMode === 'create' || modalMode === 'copy'}
minLength={modalMode === 'update' ? 0 : 1}
maxLength={255}
bind:value={formValues.clientSecret}
placeholder={modalMode === 'update'
? 'Leave empty to keep existing secret'
: 'your-client-secret'}
>
Client Secret
</PasswordField>
<TextField
required
minLength={1}
maxLength={512}
bind:value={formValues.authURL}
placeholder="https://example.com/oauth2/v2/auth"
>
Authorization URL
</TextField>
<TextField
required
minLength={1}
maxLength={512}
bind:value={formValues.tokenURL}
placeholder="https://example.com/oauth2/token"
>
Token URL
</TextField>
<TextareaField
required
minLength={1}
maxLength={512}
bind:value={formValues.scopes}
placeholder="https://example.com/auth/mail.send"
height="small"
toolTipText="Space-separated list of OAuth scopes">Scopes</TextareaField
>
{/if}
</FormColumn>
</FormColumns>
@@ -531,6 +646,156 @@
</FormGrid>
</Modal>
<Modal
headerText="Import Token"
visible={isImportModalVisible}
onClose={closeImportModal}
{isSubmitting}
>
<div class="mt-4 min-w-[800px]">
<div class="mb-4">
<p class="text-gray-600 dark:text-gray-400">
Import a pre-authorized OAuth token that was obtained outside of PhishingClub.
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md mb-4">
<button
type="button"
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
on:click={() => {
navigator.clipboard.writeText(`[
{
"access_token": "eyJ0eXAiOi...",
"refresh_token": "1.AXkAwC...",
"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/.../oauth2/v2.0/token"
}
]`);
addToast('Copied format example to clipboard', 'Success');
}}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>
Copy format example
</button>
</div>
<div class="mb-4">
<label
for="import-token-textarea"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Token JSON
</label>
<textarea
id="import-token-textarea"
bind:value={importTokensText}
required
placeholder={`[
{
"access_token": "eyJ0eXAiOi...",
"refresh_token": "1.AXkAwC...",
"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/.../oauth2/v2.0/token"
}
]`}
class="w-full h-96 px-3 py-2 text-sm font-mono bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-gray-200 resize-none"
></textarea>
</div>
</div>
<FormGrid on:submit={onClickImport} {isSubmitting}>
<FormColumns>
<FormColumn>
<!-- Empty form column for structure -->
</FormColumn>
</FormColumns>
<FormError message={importFormError} />
<FormFooter closeModal={closeImportModal} {isSubmitting} />
</FormGrid>
</Modal>
<Modal headerText="Export Token" visible={isExportModalVisible} onClose={closeExportModal}>
<div class="mt-4 min-w-[800px]">
<!-- Expiration Info Section -->
<div class="mb-4">
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-300">Token Information</h3>
<p class="text-gray-600 dark:text-gray-400 mt-2">
<span class="font-medium">Expires at:</span>
<span class="text-pc-darkblue dark:text-white font-semibold ml-2"
>{exportTokenExpiry}</span
>
</p>
</div>
<!-- Token JSON Section -->
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md">
<div class="mb-3">
<label
for="export-token-textarea"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Token JSON
</label>
<textarea
id="export-token-textarea"
readonly
value={exportTokensText}
class="w-full h-96 px-3 py-2 text-sm font-mono bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-gray-200 resize-none"
/>
</div>
<button
type="button"
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
on:click={onClickCopyExport}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>
Copy to clipboard
</button>
</div>
</div>
<FormGrid on:submit={closeExportModal}>
<FormColumns>
<FormColumn>
<!-- Empty form column for structure -->
</FormColumn>
</FormColumns>
<div
class="py-4 row-span-2 col-start-1 col-span-3 border-t-2 border-gray-200 dark:border-gray-700 w-full flex flex-row justify-center items-center sm:justify-center md:justify-center lg:justify-end xl:justify-end 2xl:justify-end bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
>
<button
type="button"
on:click={closeExportModal}
class="bg-blue-600 hover:bg-blue-500 dark:bg-blue-500 dark:hover:bg-blue-400 text-sm uppercase font-bold px-4 py-2 text-white rounded-md transition-colors duration-200"
>
Close
</button>
</div>
</FormGrid>
</Modal>
<DeleteAlert
name={deleteValues.name}
onClick={() => onClickDelete(deleteValues.id)}