mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-06 01:39:04 +02:00
FRANCIS_HOST decides where the Francis actor runtime lives. When set to "embedded" (the default), Pocket ID starts the runtime inside its own process. Any other value is the address, or a comma-separated list of addresses, of a standalone Francis runtime. Pocket ID then connects to it as a remote actor host and starts no embedded runtime. Because when using a remote runtime, it's likewise not possible to enforce a single instance of Pocket ID is running at once, the env vars currently have the `EXPERIMENTAL_` prefix, are **undocumented**, and show a warning if used. Notes: - Connecting to a standalone runtime also needs FRANCIS_HOST_PSK or FRANCIS_HOST_JWT_FILE, and optionally (but recommended) FRANCIS_CA. - When connecting to a remote runtime, exporting Pocket ID data does not include the actor state, which will need to be backed up and restored separately
1046 lines
37 KiB
Go
1046 lines
37 KiB
Go
//go:build e2etest
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"log/slog"
|
|
"path"
|
|
"time"
|
|
"uuid"
|
|
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/italypaleale/francis/actor"
|
|
francishost "github.com/italypaleale/francis/host"
|
|
"github.com/lestrrat-go/jwx/v4/jwa"
|
|
"github.com/lestrrat-go/jwx/v4/jwk"
|
|
"github.com/lestrrat-go/jwx/v4/jwt"
|
|
"github.com/ory/fosite"
|
|
"github.com/ory/fosite/compose"
|
|
fositejwt "github.com/ory/fosite/token/jwt"
|
|
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/api"
|
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
|
"github.com/pocket-id/pocket-id/backend/internal/emailverification"
|
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
|
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
|
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
|
|
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
|
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
|
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
|
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
|
|
"github.com/pocket-id/pocket-id/backend/resources"
|
|
)
|
|
|
|
// LdapSyncer runs a full LDAP synchronization
|
|
// It's an interface so this package doesn't import the ldapsync package, which imports this one in its tests
|
|
type LdapSyncer interface {
|
|
SyncAll(ctx context.Context, dbConfig *appconfig.AppConfigModel) error
|
|
}
|
|
|
|
type TestService struct {
|
|
db *gorm.DB
|
|
actors francishost.Host
|
|
jwtService *JwtService
|
|
appConfigService *appconfig.AppConfigService
|
|
ldapSyncer LdapSyncer
|
|
fileStorage storage.FileStorage
|
|
externalIdPKey jwk.Key
|
|
}
|
|
|
|
const (
|
|
e2eRefreshTokenUserID = "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
|
e2eRefreshTokenClientID = "3654a746-35d4-4321-ac61-0bdcff2b4055"
|
|
e2eRefreshTokenValidFixtureToken = "ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo"
|
|
e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4"
|
|
e2eEmailVerificationUserID = "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
|
|
e2eEmailVerificationUserEmail = "craig.federighi@test.com"
|
|
e2eEmailVerificationToken = "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup"
|
|
)
|
|
|
|
func NewTestService(db *gorm.DB, actors francishost.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapSyncer LdapSyncer, fileStorage storage.FileStorage) (*TestService, error) {
|
|
s := &TestService{
|
|
db: db,
|
|
actors: actors,
|
|
appConfigService: appConfigService,
|
|
jwtService: jwtService,
|
|
ldapSyncer: ldapSyncer,
|
|
fileStorage: fileStorage,
|
|
}
|
|
err := s.initExternalIdP()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize external IdP: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Initializes the "external IdP"
|
|
// This creates a new "issuing authority" containing a public JWKS
|
|
// It also stores the private key internally that will be used to issue JWTs
|
|
func (s *TestService) initExternalIdP() error {
|
|
// Generate a new ECDSA key
|
|
rawKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate private key: %w", err)
|
|
}
|
|
|
|
s.externalIdPKey, err = jwkutils.ImportRawKey(rawKey, jwa.ES256().String(), "")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to import private key: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// seededClientCredentials returns credentials holding a single client secret with the given value, which E2E tests use to authenticate as the client
|
|
func seededClientCredentials(secretID string, value string) model.OidcClientCredentials {
|
|
return model.OidcClientCredentials{
|
|
Secrets: seededClientSecrets(secretID, value),
|
|
}
|
|
}
|
|
|
|
// seededClientSecrets returns a single never-expiring client secret with the given value
|
|
func seededClientSecrets(secretID string, value string) []model.OidcClientSecret {
|
|
return []model.OidcClientSecret{
|
|
{
|
|
ID: secretID,
|
|
Algorithm: model.OidcClientSecretHashSHA256,
|
|
Hash: utils.CreateSha256Hash(value),
|
|
Prefix: value[:model.OidcClientSecretPrefixLength],
|
|
CreatedAt: datatype.DateTime(time.Now()),
|
|
},
|
|
}
|
|
}
|
|
|
|
//nolint:gocognit
|
|
func (s *TestService) SeedDatabase(baseURL string) error {
|
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
users := []model.User{
|
|
{
|
|
Base: model.Base{
|
|
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
|
},
|
|
Username: "tim",
|
|
Email: new("tim.cook@test.com"),
|
|
EmailVerified: true,
|
|
FirstName: "Tim",
|
|
LastName: "Cook",
|
|
DisplayName: "Tim Cook",
|
|
IsAdmin: true,
|
|
},
|
|
{
|
|
Base: model.Base{
|
|
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
|
},
|
|
Username: "craig",
|
|
Email: new("craig.federighi@test.com"),
|
|
EmailVerified: false,
|
|
FirstName: "Craig",
|
|
LastName: "Federighi",
|
|
DisplayName: "Craig Federighi",
|
|
IsAdmin: false,
|
|
},
|
|
{
|
|
Base: model.Base{
|
|
ID: "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
|
|
},
|
|
Username: "eddy",
|
|
Email: new("eddy.cue@test.com"),
|
|
FirstName: "Eddy",
|
|
LastName: "Cue",
|
|
DisplayName: "Eddy Cue",
|
|
IsAdmin: false,
|
|
},
|
|
}
|
|
for _, user := range users {
|
|
if err := tx.Create(&user).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
userGroups := []model.UserGroup{
|
|
{
|
|
Base: model.Base{
|
|
ID: "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
|
|
},
|
|
Name: "developers",
|
|
FriendlyName: "Developers",
|
|
Users: []model.User{users[0], users[1]},
|
|
},
|
|
{
|
|
Base: model.Base{
|
|
ID: "adab18bf-f89d-4087-9ee1-70ff15b48211",
|
|
},
|
|
Name: "designers",
|
|
FriendlyName: "Designers",
|
|
Users: []model.User{users[0]},
|
|
},
|
|
}
|
|
for _, group := range userGroups {
|
|
if err := tx.Create(&group).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
oidcClients := []model.OidcClient{
|
|
{
|
|
Base: model.Base{
|
|
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
|
},
|
|
Name: "Nextcloud",
|
|
Description: "This is an example description for Nextcloud",
|
|
LaunchURL: new("https://nextcloud.local"),
|
|
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000001", "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY"),
|
|
CallbackURLs: datatype.StringList{"http://nextcloud.localhost/auth/callback"},
|
|
LogoutCallbackURLs: datatype.StringList{"http://nextcloud.localhost/auth/logout/callback"},
|
|
ImageType: new("png"),
|
|
CreatedByID: new(users[0].ID),
|
|
},
|
|
{
|
|
Base: model.Base{
|
|
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
|
},
|
|
Name: "Immich",
|
|
LaunchURL: new("https://immich.local"),
|
|
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000002", "PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x"),
|
|
CallbackURLs: datatype.StringList{"http://immich.localhost/auth/callback"},
|
|
CreatedByID: new(users[1].ID),
|
|
IsGroupRestricted: true,
|
|
AllowedUserGroups: []model.UserGroup{
|
|
userGroups[1],
|
|
},
|
|
},
|
|
{
|
|
Base: model.Base{
|
|
ID: "7c21a609-96b5-4011-9900-272b8d31a9d1",
|
|
},
|
|
Name: "Tailscale",
|
|
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000003", "n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo"),
|
|
CallbackURLs: datatype.StringList{"http://tailscale.localhost/auth/callback"},
|
|
LogoutCallbackURLs: datatype.StringList{"http://tailscale.localhost/auth/logout/callback"},
|
|
IsGroupRestricted: true,
|
|
CreatedByID: new(users[0].ID),
|
|
AllowedUserGroups: []model.UserGroup{
|
|
userGroups[0],
|
|
},
|
|
},
|
|
{
|
|
Base: model.Base{
|
|
ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
|
},
|
|
Name: "Federated",
|
|
CallbackURLs: datatype.StringList{"http://federated.localhost/auth/callback"},
|
|
CreatedByID: new(users[1].ID),
|
|
AllowedUserGroups: []model.UserGroup{},
|
|
Credentials: model.OidcClientCredentials{
|
|
Secrets: seededClientSecrets("2f1b8f1a-1d3e-4f0c-9c1a-000000000004", "PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x"),
|
|
FederatedIdentities: []model.OidcClientFederatedIdentity{
|
|
{
|
|
Issuer: "https://external-idp.local",
|
|
Audience: "api://PocketID",
|
|
Subject: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
|
JWKS: baseURL + "/api/externalidp/jwks.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Base: model.Base{
|
|
ID: "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
|
|
},
|
|
Name: "SCIM Client",
|
|
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000005", "nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn"),
|
|
CallbackURLs: datatype.StringList{"http://scimclient.localhost/auth/callback"},
|
|
CreatedByID: new(users[0].ID),
|
|
IsGroupRestricted: true,
|
|
AllowedUserGroups: []model.UserGroup{
|
|
userGroups[0],
|
|
userGroups[1],
|
|
},
|
|
},
|
|
{
|
|
Base: model.Base{
|
|
ID: "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
|
|
},
|
|
Name: "PAR Test Client",
|
|
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000006", "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY"),
|
|
CallbackURLs: datatype.StringList{"http://par-client.localhost/auth/callback"},
|
|
CreatedByID: new(users[0].ID),
|
|
},
|
|
{
|
|
Base: model.Base{
|
|
ID: "e1f2a3b4-c5d6-7890-abcd-ef0000000002",
|
|
},
|
|
Name: "Skip Consent Client",
|
|
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000007", "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY"),
|
|
CallbackURLs: datatype.StringList{"http://skip-consent.localhost/auth/callback"},
|
|
CreatedByID: new(users[0].ID),
|
|
// Trusted client that bypasses the consent screen by default
|
|
SkipConsent: true,
|
|
},
|
|
}
|
|
for _, client := range oidcClients {
|
|
if err := tx.Create(&client).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
farFuture := datatype.DateTime(time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
oauth2Session := oidc.OAuth2Session{
|
|
Base: model.Base{
|
|
ID: "551ab785-c830-47d3-8a07-60c9f3bb4859",
|
|
},
|
|
Kind: "access_token",
|
|
Key: "cross-database-test-session",
|
|
RequestID: "cross-database-test-request",
|
|
ClientID: oidcClients[0].ID,
|
|
AccessTokenSignature: "",
|
|
Active: true,
|
|
RequestData: `{"client_id":"3654a746-35d4-4321-ac61-0bdcff2b4055","session":{"subject":"f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e","id_token_claims":{"jti":"cross-database-test-id-token-jti"}}}`,
|
|
ExpiresAt: &farFuture,
|
|
}
|
|
if err := tx.Create(&oauth2Session).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Table("oauth2_jtis").Create(map[string]any{
|
|
"id": "bd0c8bf2-66ec-487a-9dd5-7d9d78d73543",
|
|
"created_at": datatype.DateTime(time.Now()),
|
|
"jti": "cross-database-test-jti",
|
|
"expires_at": farFuture,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
interactionSession := oidc.InteractionSession{
|
|
Base: model.Base{
|
|
ID: "aaf5dd23-cd1f-4748-a2aa-baa6af94d800",
|
|
},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: oidcClients[0].ID,
|
|
UserID: new(users[0].ID),
|
|
ConsentRequired: true,
|
|
RequestedAt: farFuture,
|
|
Parameters: oidc.InteractionSessionParameters{
|
|
"client_id": oidcClients[0].ID,
|
|
},
|
|
}
|
|
if err := tx.Create(&interactionSession).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
reauthenticationToken := webauthn.ReauthenticationToken{
|
|
Base: model.Base{
|
|
ID: "71839ace-d978-4e6f-8fb1-b8648a21031b",
|
|
},
|
|
Token: "cross-database-reauthentication-token",
|
|
ExpiresAt: farFuture,
|
|
UserID: users[0].ID,
|
|
}
|
|
if err := tx.Create(&reauthenticationToken).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
userAuthorizedClients := []model.UserAuthorizedOidcClient{
|
|
{
|
|
Scope: datatype.StringList{"openid", "profile", "email"},
|
|
UserID: users[0].ID,
|
|
ClientID: oidcClients[0].ID,
|
|
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 1, 13, 0, 0, 0, time.UTC)),
|
|
},
|
|
{
|
|
Scope: datatype.StringList{"openid", "profile", "email"},
|
|
UserID: users[0].ID,
|
|
ClientID: oidcClients[2].ID,
|
|
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 10, 14, 0, 0, 0, time.UTC)),
|
|
},
|
|
{
|
|
Scope: datatype.StringList{"openid", "profile", "email"},
|
|
UserID: users[1].ID,
|
|
ClientID: oidcClients[3].ID,
|
|
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
|
|
},
|
|
{
|
|
Scope: datatype.StringList{"openid", "profile", "email"},
|
|
UserID: users[0].ID,
|
|
ClientID: oidcClients[5].ID,
|
|
LastUsedAt: datatype.DateTime(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)),
|
|
},
|
|
}
|
|
for _, userAuthorizedClient := range userAuthorizedClients {
|
|
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
ordersAPI := api.API{
|
|
Base: model.Base{
|
|
ID: "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
|
},
|
|
Name: "Orders API",
|
|
Audience: "https://api.orders.test",
|
|
}
|
|
if err := tx.Create(&ordersAPI).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
apiPermissions := []api.Permission{
|
|
{
|
|
Base: model.Base{
|
|
ID: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
|
|
},
|
|
APIID: ordersAPI.ID,
|
|
Key: "read:orders",
|
|
Name: "Read orders",
|
|
Description: new("Read order data"),
|
|
},
|
|
{
|
|
Base: model.Base{
|
|
ID: "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
|
|
},
|
|
APIID: ordersAPI.ID,
|
|
Key: "write:orders",
|
|
Name: "Write orders",
|
|
Description: new("Create and modify orders"),
|
|
},
|
|
}
|
|
for _, permission := range apiPermissions {
|
|
if err := tx.Create(&permission).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Immich may reach the Orders API for both subject types, which is what the permission grants below build on
|
|
allowedAPIs := []api.OidcClientAllowedAPI{
|
|
{
|
|
OidcClientID: oidcClients[1].ID,
|
|
APIID: ordersAPI.ID,
|
|
SubjectType: oidc.SubjectTypeUser,
|
|
},
|
|
{
|
|
OidcClientID: oidcClients[1].ID,
|
|
APIID: ordersAPI.ID,
|
|
SubjectType: oidc.SubjectTypeClient,
|
|
},
|
|
}
|
|
for _, allowed := range allowedAPIs {
|
|
if err := tx.Create(&allowed).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Immich is allowed to request read:orders on behalf of users and to obtain write:orders for itself via the client credentials grant
|
|
allowedAPIPermissions := []api.OidcClientAllowedAPIPermission{
|
|
{
|
|
OidcClientID: oidcClients[1].ID,
|
|
APIPermissionID: apiPermissions[0].ID,
|
|
SubjectType: oidc.SubjectTypeUser,
|
|
},
|
|
{
|
|
OidcClientID: oidcClients[1].ID,
|
|
APIPermissionID: apiPermissions[1].ID,
|
|
SubjectType: oidc.SubjectTypeClient,
|
|
},
|
|
}
|
|
for _, allowed := range allowedAPIPermissions {
|
|
if err := tx.Create(&allowed).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// To generate a new key pair, run the following command:
|
|
// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \
|
|
// openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout)
|
|
|
|
publicKeyPasskey1, _ := base64.StdEncoding.DecodeString("pQMmIAEhWCDBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHCJYIIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmGAQI=")
|
|
publicKeyPasskey2, _ := base64.StdEncoding.DecodeString("pSJYIPmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouHAQIDJiABIVggj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbI=")
|
|
webauthnCredentials := []model.WebauthnCredential{
|
|
{
|
|
Name: "Passkey 1",
|
|
CredentialID: []byte("test-credential-tim"),
|
|
PublicKey: publicKeyPasskey1,
|
|
AttestationType: "none",
|
|
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
|
UserID: users[0].ID,
|
|
},
|
|
{
|
|
Name: "Passkey 2",
|
|
CredentialID: []byte("test-credential-craig"),
|
|
PublicKey: publicKeyPasskey2,
|
|
AttestationType: "none",
|
|
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
|
UserID: users[1].ID,
|
|
},
|
|
}
|
|
for _, credential := range webauthnCredentials {
|
|
if err := tx.Create(&credential).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
webauthnSession := webauthn.WebauthnSession{
|
|
Challenge: "challenge",
|
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
|
UserVerification: "preferred",
|
|
CredentialParams: webauthn.CredentialParameters{
|
|
{Type: "public-key", Algorithm: -7},
|
|
{Type: "public-key", Algorithm: -257},
|
|
},
|
|
}
|
|
if err := tx.Create(&webauthnSession).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
apiKeys := []apikey.ApiKey{
|
|
{
|
|
Base: model.Base{
|
|
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
|
|
},
|
|
Name: "Test API Key",
|
|
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
|
|
UserID: users[0].ID,
|
|
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
|
|
},
|
|
{
|
|
Base: model.Base{
|
|
ID: "98900330-7a7b-48fe-881b-2cc6ad049976",
|
|
},
|
|
Name: "Expired API Key",
|
|
Key: "141ff8ac9db640ba93630099de83d0ead8e7ac673e3a7d31b4fd7ff2252e6389",
|
|
UserID: users[0].ID,
|
|
ExpiresAt: datatype.DateTime(time.Now().Add(-20 * 24 * time.Hour)),
|
|
},
|
|
}
|
|
for _, apiKey := range apiKeys {
|
|
if err := tx.Create(&apiKey).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
keyValues := []model.KV{
|
|
{
|
|
Key: jwkutils.PrivateKeyDBKey,
|
|
// {"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}
|
|
Value: new("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"),
|
|
},
|
|
{
|
|
Key: jwkutils.SessionKeyDBKey,
|
|
// {"alg":"HS256","k":"5un_Rh6BPDVwGwRWPC_-w-HvT4BuUq5vYE4a2z4IL1k","kid":"YC7IX6YEFJc","kty":"oct","use":"sig"}
|
|
Value: new("6puTIBpn0u2Y8FJQ9C4gxuzORTgMmac9Tz9B2epw212hlaepET06ca/CPnwdirCNNg/tLG1wXd2MNSEMgZAnl1cPkF9hPabrRW+SUYpFDLu4yE9w5uc6ns//9pphedK5vS190oXcE7FaWBoso789JuQ0yoicNEnjAAjBWExnp0dXXufmXzUZSnKQ"),
|
|
},
|
|
}
|
|
|
|
for _, kv := range keyValues {
|
|
if err := tx.Create(&kv).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Actor-backed token fixtures are seeded separately from the database transaction to avoid invoking actors while SQLite holds a transaction
|
|
err = s.seedOneTimeAccessTokens(context.Background())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to seed one-time access tokens: %w", err)
|
|
}
|
|
|
|
err = s.seedSignupTokens(context.Background())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to seed signup tokens: %w", err)
|
|
}
|
|
|
|
err = s.seedEmailVerificationToken(context.Background())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to seed email verification token: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// seedEmailVerificationToken replaces the outstanding verification state so every E2E reset starts from the same valid token
|
|
func (s *TestService) seedEmailVerificationToken(ctx context.Context) error {
|
|
state := emailverification.State{
|
|
TokenHash: utils.CreateSha256Hash(e2eEmailVerificationToken),
|
|
Email: e2eEmailVerificationUserEmail,
|
|
ExpiresAt: time.Now().Add(24 * time.Hour).Round(time.Second),
|
|
}
|
|
|
|
_, err := s.actors.Service().Invoke(ctx, emailverification.ActorType, e2eEmailVerificationUserID, emailverification.MethodIssue, state)
|
|
return err
|
|
}
|
|
|
|
// seedSignupTokens seeds the signup tokens used by E2E tests into the signup token singleton actor.
|
|
// The already-expired fixture token is intentionally not seeded, since the actor would purge it right away via its cleanup alarm.
|
|
func (s *TestService) seedSignupTokens(ctx context.Context) error {
|
|
now := time.Now().Round(time.Second)
|
|
tokens := map[string]usersignup.SignupTokenState{
|
|
"VALID1234567890A": {
|
|
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExpiresAt: now.Add(24 * time.Hour),
|
|
UsageLimit: 1,
|
|
UsageCount: 0,
|
|
UserGroupIDs: []string{"c7ae7c01-28a3-4f3c-9572-1ee734ea8368"},
|
|
CreatedAt: now,
|
|
},
|
|
"PARTIAL567890ABC": {
|
|
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
|
|
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
|
UsageLimit: 5,
|
|
UsageCount: 2,
|
|
CreatedAt: now,
|
|
},
|
|
"FULLYUSED567890C": {
|
|
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
|
|
ExpiresAt: now.Add(24 * time.Hour),
|
|
UsageLimit: 1,
|
|
UsageCount: 1, // Usage limit reached
|
|
CreatedAt: now,
|
|
},
|
|
}
|
|
|
|
// The actor state store isn't wiped by ResetDatabase, so remove any signup token left over from a previous test first
|
|
err := s.deleteAllSignupTokens(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Each signup token is its own actor, whose actor ID is the token's value
|
|
for token, state := range tokens {
|
|
_, err = s.actors.Service().Invoke(ctx, usersignup.SignupTokenActorType, token, usersignup.SignupTokenMethodCreate, state)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to seed signup token %q: %w", token, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// deleteAllSignupTokens removes every signup token currently stored in the actor state store
|
|
func (s *TestService) deleteAllSignupTokens(ctx context.Context) error {
|
|
var after string
|
|
for {
|
|
res, err := s.actors.Service().ListStates(ctx, usersignup.SignupTokenActorType, &actor.ListStatesOpts{After: after})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list signup tokens: %w", err)
|
|
}
|
|
|
|
for _, st := range res.States {
|
|
_, err = s.actors.Service().Invoke(ctx, usersignup.SignupTokenActorType, st.ActorID, usersignup.SignupTokenMethodDelete, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete signup token %q: %w", st.ActorID, err)
|
|
}
|
|
}
|
|
|
|
// An empty cursor means we've just read the last page
|
|
after = res.AfterID()
|
|
if after == "" {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// seedOneTimeAccessTokens seeds the one-time access tokens used by E2E tests into the actor state store.
|
|
// Expired tokens are intentionally not seeded: with actor-backed storage an expired token is simply one that has no state, which the exchange flow already reports as invalid/expired.
|
|
func (s *TestService) seedOneTimeAccessTokens(ctx context.Context) error {
|
|
tokens := []struct {
|
|
token string
|
|
ttl time.Duration
|
|
}{
|
|
{token: "HPe6k6u1DRRV", ttl: time.Hour},
|
|
{token: "0ne-t1me-t0ken", ttl: time.Hour},
|
|
}
|
|
|
|
for _, t := range tokens {
|
|
state := onetimeaccess.TokenState{
|
|
UserID: e2eRefreshTokenUserID,
|
|
ExpiresAt: time.Now().Add(t.ttl).Round(time.Second),
|
|
}
|
|
// Seed through the actor's "restore" method (which sets the state) rather than writing the
|
|
// state directly: if an actor for this token is still active from a previous test (for
|
|
// example, one whose token was already consumed), invoking it refreshes its in-memory cache
|
|
// too, whereas a direct state write would leave that cache stale.
|
|
_, err := s.actors.Service().Invoke(ctx, onetimeaccess.TokenActorType, t.token, onetimeaccess.TokenMethodRestore, state)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to seed one-time access token %q: %w", t.token, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *TestService) ResetDatabase() error {
|
|
return s.db.Transaction(func(tx *gorm.DB) (err error) {
|
|
var tables []string
|
|
|
|
// The "francis_" tables belong to the actor host and must be preserved: wiping them out from under the running host breaks it
|
|
switch common.EnvConfig.DbProvider {
|
|
case common.DbProviderSqlite:
|
|
// Query to get all tables for SQLite
|
|
err = tx.
|
|
Raw(`SELECT name
|
|
FROM sqlite_master
|
|
WHERE type='table'
|
|
AND name NOT LIKE 'sqlite_%'
|
|
AND name NOT LIKE 'francis_%'
|
|
AND name != 'schema_migrations'`).
|
|
Scan(&tables).
|
|
Error
|
|
if err != nil {
|
|
return fmt.Errorf("error loading table list: %w", err)
|
|
}
|
|
case common.DbProviderPostgres:
|
|
// Query to get all tables for PostgreSQL
|
|
err = tx.
|
|
Raw(`SELECT tablename
|
|
FROM pg_tables
|
|
WHERE schemaname = 'public'
|
|
AND tablename NOT LIKE 'francis_%'
|
|
AND tablename != 'schema_migrations'`).
|
|
Scan(&tables).
|
|
Error
|
|
if err != nil {
|
|
return fmt.Errorf("error loading table list: %w", err)
|
|
}
|
|
default:
|
|
return fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
|
}
|
|
|
|
// Delete all rows from all tables
|
|
for _, table := range tables {
|
|
err = tx.Exec("DELETE FROM " + table).Error
|
|
if err != nil {
|
|
return fmt.Errorf("error deleting from table '%s': %w", table, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *TestService) ResetApplicationImages(ctx context.Context) error {
|
|
err := s.fileStorage.DeleteAll(ctx, "/")
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Error removing uploads", slog.Any("error", err))
|
|
return err
|
|
}
|
|
|
|
files, err := resources.FS.ReadDir("images")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, file := range files {
|
|
if file.IsDir() {
|
|
continue
|
|
}
|
|
srcFilePath := path.Join("images", file.Name())
|
|
srcFile, err := resources.FS.Open(srcFilePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = s.fileStorage.Save(ctx, path.Join("application-images", file.Name()), srcFile)
|
|
if err != nil {
|
|
srcFile.Close()
|
|
return err
|
|
}
|
|
srcFile.Close()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *TestService) ResetAppConfig(ctx context.Context) error {
|
|
// Reset all application configuration values through the singleton actor
|
|
_, err := s.appConfigService.UpdateAppConfig(ctx, dto.AppConfigUpdateDto{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Manually set the instance ID used to derive the JWK encryption key, so the seeded JWK can be decrypted
|
|
// Persist the fixed test value so it survives an export/import round-trip
|
|
const testInstanceID = "test-instance-id"
|
|
err = s.db.WithContext(ctx).
|
|
Exec(
|
|
`INSERT INTO kv (key, value) VALUES ('instance_id', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`,
|
|
testInstanceID,
|
|
).
|
|
Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The instance ID is loaded once at startup, so we also set it directly on the JWT service so it takes effect immediately
|
|
s.jwtService.instanceID = testInstanceID
|
|
|
|
// Reload the JWK
|
|
if err := s.jwtService.LoadOrGenerateKey(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SyncLdap triggers an LDAP synchronization
|
|
func (s *TestService) SyncLdap(ctx context.Context) error {
|
|
dbConfig, err := s.appConfigService.GetConfig(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("error loading app configuration: %w", err)
|
|
}
|
|
return s.ldapSyncer.SyncAll(ctx, dbConfig)
|
|
}
|
|
|
|
// SetLdapTestConfig updates the LDAP configuration used by the end-to-end test server
|
|
func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
|
|
err := s.appConfigService.UpdateAppConfigValues(ctx,
|
|
"ldapUrl", "ldap://lldap:3890",
|
|
"ldapBindDn", "uid=admin,ou=people,dc=pocket-id,dc=org",
|
|
"ldapBindPassword", "admin_password",
|
|
"ldapBase", "dc=pocket-id,dc=org",
|
|
"ldapUserSearchFilter", "(objectClass=person)",
|
|
"ldapUserGroupSearchFilter", "(objectClass=groupOfNames)",
|
|
"ldapSkipCertVerify", "true",
|
|
"ldapAttributeUserUniqueIdentifier", "uuid",
|
|
"ldapAttributeUserUsername", "uid",
|
|
"ldapAttributeUserEmail", "mail",
|
|
"ldapAttributeUserFirstName", "givenName",
|
|
"ldapAttributeUserLastName", "sn",
|
|
"ldapAttributeGroupUniqueIdentifier", "uuid",
|
|
"ldapAttributeGroupName", "uid",
|
|
"ldapAttributeGroupMember", "member",
|
|
"ldapAdminGroupName", "admin_group",
|
|
"ldapSoftDeleteUsers", "true",
|
|
"ldapEnabled", "true",
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set LDAP test config: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *TestService) SignRefreshToken(ctx context.Context, userID, clientID, fixtureRefreshToken string) (string, error) {
|
|
globalSecret, err := oidc.DeriveGlobalSecret(common.EnvConfig.EncryptionKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{
|
|
GlobalSecret: globalSecret,
|
|
RefreshTokenLifespan: RefreshTokenDuration,
|
|
})
|
|
|
|
token, signature, err := strategy.GenerateRefreshToken(ctx, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// The e2e API always returns a newly generated fosite token. The legacy fixture
|
|
// token only selects whether to seed the matching stored session as valid/expired.
|
|
session, ok := seededRefreshTokenSession(userID, clientID, fixtureRefreshToken)
|
|
if !ok {
|
|
return token, nil
|
|
}
|
|
session.Signature = signature
|
|
|
|
if err := s.seedFositeTokenSession(ctx, session); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
func seededRefreshTokenSession(userID string, clientID string, fixtureRefreshToken string) (fositeTokenSession, bool) {
|
|
expired, ok := seededRefreshTokenFixture(userID, clientID, fixtureRefreshToken)
|
|
if !ok {
|
|
return fositeTokenSession{}, false
|
|
}
|
|
|
|
expiresAt := time.Now().UTC().Add(24 * time.Hour)
|
|
if expired {
|
|
expiresAt = time.Now().UTC().Add(-24 * time.Hour)
|
|
}
|
|
|
|
return fositeTokenSession{
|
|
Kind: "refresh_token",
|
|
RequestID: "e2e-refresh-" + utils.CreateSha256Hash(fixtureRefreshToken),
|
|
UserID: userID,
|
|
ClientID: clientID,
|
|
AuthenticationMethod: AuthenticationMethodPhishingResistant,
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
TokenType: fosite.RefreshToken,
|
|
ExpiresAt: expiresAt,
|
|
}, true
|
|
}
|
|
|
|
func seededRefreshTokenFixture(userID string, clientID string, fixtureRefreshToken string) (expired bool, ok bool) {
|
|
if userID != e2eRefreshTokenUserID || clientID != e2eRefreshTokenClientID {
|
|
return false, false
|
|
}
|
|
|
|
switch fixtureRefreshToken {
|
|
case e2eRefreshTokenValidFixtureToken:
|
|
return false, true
|
|
case e2eRefreshTokenExpiredFixtureToken:
|
|
return true, true
|
|
default:
|
|
return false, false
|
|
}
|
|
}
|
|
|
|
func (s *TestService) SignAccessToken(ctx context.Context, userID, clientID string, expired bool) (string, error) {
|
|
globalSecret, err := oidc.DeriveGlobalSecret(common.EnvConfig.EncryptionKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
fositeConfig := &fosite.Config{
|
|
GlobalSecret: globalSecret,
|
|
AccessTokenLifespan: AccessTokenDuration,
|
|
AccessTokenIssuer: common.EnvConfig.AppURL,
|
|
}
|
|
coreStrategy := compose.NewOAuth2HMACStrategy(fositeConfig)
|
|
keyGetter := func(context.Context) (interface{}, error) {
|
|
return oidc.SigningKeyFromSigner(s.jwtService)
|
|
}
|
|
strategy := oidc.NewAccessTokenStrategy(compose.NewOAuth2RFC9068JWTStrategy(keyGetter, coreStrategy, fositeConfig), common.EnvConfig.AppURL)
|
|
|
|
expiresAt := time.Now().UTC().Add(AccessTokenDuration)
|
|
if expired {
|
|
expiresAt = time.Now().UTC().Add(-time.Minute)
|
|
}
|
|
|
|
session := fositeTokenSession{
|
|
Kind: "access_token",
|
|
RequestID: "e2e-access-" + uuid.NewV4().String(),
|
|
UserID: userID,
|
|
ClientID: clientID,
|
|
AuthenticationMethod: AuthenticationMethodPhishingResistant,
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
TokenType: fosite.AccessToken,
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
|
|
request := s.newFositeTokenRequest(session)
|
|
token, signature, err := strategy.GenerateAccessToken(ctx, request)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
session.Signature = signature
|
|
err = s.seedFositeTokenSession(ctx, session)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
type fositeTokenSession struct {
|
|
Kind string
|
|
Signature string
|
|
RequestID string
|
|
UserID string
|
|
ClientID string
|
|
AuthenticationMethod string
|
|
Scopes []string
|
|
TokenType fosite.TokenType
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
func (s *TestService) seedFositeTokenSession(ctx context.Context, session fositeTokenSession) error {
|
|
request := s.newFositeTokenRequest(session)
|
|
|
|
store := oidc.
|
|
NewStore(s.db, nil).
|
|
WithIssuer(common.EnvConfig.AppURL)
|
|
switch session.Kind {
|
|
case "access_token":
|
|
return store.CreateAccessTokenSession(ctx, session.Signature, request)
|
|
case "refresh_token":
|
|
return store.CreateRefreshTokenSession(ctx, session.Signature, "", request)
|
|
default:
|
|
return fmt.Errorf("unsupported token session kind %q", session.Kind)
|
|
}
|
|
}
|
|
|
|
func (s *TestService) newFositeTokenRequest(session fositeTokenSession) *fosite.Request {
|
|
requestedAt := time.Now().UTC()
|
|
|
|
oidcSession := &oidc.Session{
|
|
Subject: session.UserID,
|
|
AuthenticationMethod: session.AuthenticationMethod,
|
|
Claims: &fositejwt.IDTokenClaims{
|
|
RequestedAt: requestedAt,
|
|
AuthTime: requestedAt,
|
|
Subject: session.UserID,
|
|
Issuer: common.EnvConfig.AppURL,
|
|
},
|
|
}
|
|
oidcSession.SetExpiresAt(session.TokenType, session.ExpiresAt)
|
|
|
|
request := fosite.NewRequest()
|
|
request.ID = session.RequestID
|
|
request.RequestedAt = requestedAt
|
|
request.Client = oidc.Client{OidcClient: model.OidcClient{Base: model.Base{ID: session.ClientID}}}
|
|
request.RequestedScope = session.Scopes
|
|
request.GrantedScope = session.Scopes
|
|
request.RequestedAudience = fosite.Arguments{session.ClientID}
|
|
request.GrantedAudience = fosite.Arguments{session.ClientID}
|
|
request.Session = oidcSession
|
|
|
|
return request
|
|
}
|
|
|
|
// GetExternalIdPJWKS returns the JWKS for the "external IdP".
|
|
func (s *TestService) GetExternalIdPJWKS() (jwk.Set, error) {
|
|
pubKey, err := s.externalIdPKey.PublicKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get public key: %w", err)
|
|
}
|
|
|
|
set := jwk.NewSet()
|
|
err = set.AddKey(pubKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to add public key to set: %w", err)
|
|
}
|
|
|
|
return set, nil
|
|
}
|
|
|
|
func (s *TestService) SignExternalIdPToken(iss, sub, aud string) (string, error) {
|
|
now := time.Now()
|
|
token, err := jwt.NewBuilder().
|
|
Subject(sub).
|
|
Expiration(now.Add(time.Hour)).
|
|
IssuedAt(now).
|
|
JwtID(uuid.NewV4().String()).
|
|
Issuer(iss).
|
|
Audience([]string{aud}).
|
|
Build()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to build token: %w", err)
|
|
}
|
|
|
|
alg, _ := s.externalIdPKey.Algorithm()
|
|
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.externalIdPKey))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to sign token: %w", err)
|
|
}
|
|
|
|
return string(signed), nil
|
|
}
|