chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package identity
|
||||
|
||||
import "sort"
|
||||
|
||||
type Role string
|
||||
type Capability string
|
||||
|
||||
const (
|
||||
RoleOwner Role = "owner"
|
||||
RoleAdmin Role = "admin"
|
||||
RoleEditor Role = "editor"
|
||||
RoleViewer Role = "viewer"
|
||||
)
|
||||
|
||||
const (
|
||||
CapabilityHouseholdRead Capability = "household.read"
|
||||
CapabilityHouseholdManage Capability = "household.manage"
|
||||
CapabilityMemberManage Capability = "member.manage"
|
||||
CapabilityOwnershipTransfer Capability = "ownership.transfer"
|
||||
CapabilitySubjectRead Capability = "subject.read"
|
||||
CapabilitySubjectWrite Capability = "subject.write"
|
||||
CapabilityConversationRead Capability = "conversation.read"
|
||||
CapabilityStoryReview Capability = "story.review"
|
||||
CapabilityDeviceManage Capability = "device.manage"
|
||||
CapabilityFirmwareManage Capability = "firmware.manage"
|
||||
CapabilityRecordingPlay Capability = "recording.play"
|
||||
CapabilityRecordingDownload Capability = "recording.download"
|
||||
CapabilityPrivacyRead Capability = "privacy.read"
|
||||
CapabilityPrivacyManage Capability = "privacy.manage"
|
||||
)
|
||||
|
||||
var roleCapabilities = map[Role]map[Capability]struct{}{
|
||||
RoleOwner: capabilitySet(
|
||||
CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage,
|
||||
CapabilityOwnershipTransfer, CapabilitySubjectRead, CapabilitySubjectWrite,
|
||||
CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage,
|
||||
CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityPrivacyManage,
|
||||
),
|
||||
RoleAdmin: capabilitySet(
|
||||
CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage,
|
||||
CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead,
|
||||
CapabilityStoryReview, CapabilityDeviceManage,
|
||||
CapabilityFirmwareManage, CapabilityPrivacyRead,
|
||||
),
|
||||
RoleEditor: capabilitySet(
|
||||
CapabilityHouseholdRead, CapabilitySubjectRead, CapabilitySubjectWrite,
|
||||
CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage,
|
||||
),
|
||||
RoleViewer: capabilitySet(
|
||||
CapabilityHouseholdRead, CapabilitySubjectRead, CapabilityConversationRead,
|
||||
),
|
||||
}
|
||||
|
||||
func Can(role Role, capability Capability) bool {
|
||||
capabilities, ok := roleCapabilities[role]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, ok = capabilities[capability]
|
||||
return ok
|
||||
}
|
||||
|
||||
func CapabilitiesForRole(role Role) []Capability {
|
||||
set := roleCapabilities[role]
|
||||
result := make([]Capability, 0, len(set))
|
||||
for capability := range set {
|
||||
result = append(result, capability)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
||||
return result
|
||||
}
|
||||
|
||||
func capabilitySet(capabilities ...Capability) map[Capability]struct{} {
|
||||
set := make(map[Capability]struct{}, len(capabilities))
|
||||
for _, capability := range capabilities {
|
||||
set[capability] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package identity
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRoleCapabilityMatrix(t *testing.T) {
|
||||
tests := []struct {
|
||||
role Role
|
||||
capability Capability
|
||||
allowed bool
|
||||
}{
|
||||
{RoleOwner, CapabilityMemberManage, true},
|
||||
{RoleAdmin, CapabilityMemberManage, true},
|
||||
{RoleEditor, CapabilityMemberManage, false},
|
||||
{RoleViewer, CapabilitySubjectWrite, false},
|
||||
{RoleViewer, CapabilityConversationRead, true},
|
||||
{RoleAdmin, CapabilityOwnershipTransfer, false},
|
||||
{RoleOwner, CapabilityPrivacyManage, true},
|
||||
{RoleAdmin, CapabilityPrivacyRead, true},
|
||||
{RoleAdmin, CapabilityPrivacyManage, false},
|
||||
{RoleEditor, CapabilityPrivacyRead, false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if actual := Can(test.role, test.capability); actual != test.allowed {
|
||||
t.Errorf("Can(%s, %s) = %t, want %t", test.role, test.capability, actual, test.allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseholdRoleNeverGrantsRecordingAccess(t *testing.T) {
|
||||
for _, role := range []Role{RoleOwner, RoleAdmin, RoleEditor, RoleViewer} {
|
||||
if Can(role, CapabilityRecordingPlay) || Can(role, CapabilityRecordingDownload) {
|
||||
t.Fatalf("role %s must not implicitly grant recording access", role)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package identity
|
||||
|
||||
import "context"
|
||||
|
||||
type Principal struct {
|
||||
Issuer string `json:"issuer"`
|
||||
Subject string `json:"subject"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
}
|
||||
|
||||
type principalContextKey struct{}
|
||||
|
||||
func WithPrincipal(ctx context.Context, principal Principal) context.Context {
|
||||
return context.WithValue(ctx, principalContextKey{}, principal)
|
||||
}
|
||||
|
||||
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
|
||||
principal, ok := ctx.Value(principalContextKey{}).(Principal)
|
||||
return principal, ok
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrincipalContext(t *testing.T) {
|
||||
if _, ok := PrincipalFromContext(context.Background()); ok {
|
||||
t.Fatal("empty context should not contain a principal")
|
||||
}
|
||||
expected := Principal{Issuer: "issuer", Subject: "subject"}
|
||||
actual, ok := PrincipalFromContext(WithPrincipal(context.Background(), expected))
|
||||
if !ok || actual != expected {
|
||||
t.Fatalf("principal = %#v, %t, want %#v", actual, ok, expected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAccountNotFound = errors.New("platform account not found")
|
||||
ErrAccessDenied = errors.New("resource access denied")
|
||||
)
|
||||
|
||||
type HouseholdMembership struct {
|
||||
HouseholdID string `json:"household_id"`
|
||||
HouseholdName string `json:"household_name"`
|
||||
Role Role `json:"role"`
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
UserID string `json:"user_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
PolicyVersion string `json:"policy_version"`
|
||||
PlatformRole string `json:"platform_role"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Memberships []HouseholdMembership `json:"household_memberships"`
|
||||
}
|
||||
|
||||
type CurrentUser struct {
|
||||
Identity Principal `json:"identity"`
|
||||
Accounts []Account `json:"accounts"`
|
||||
}
|
||||
|
||||
type HouseholdAccess struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
UserID string `json:"user_id"`
|
||||
HouseholdID string `json:"household_id"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *Repository) CurrentUser(ctx context.Context, principal Principal) (CurrentUser, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT
|
||||
ua.public_id::text,
|
||||
t.public_id::text,
|
||||
t.name,
|
||||
t.policy_version,
|
||||
ua.platform_role,
|
||||
COALESCE(ua.display_name, ''),
|
||||
COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'household_id', h.public_id::text,
|
||||
'household_name', h.name,
|
||||
'role', hm.role
|
||||
) ORDER BY h.name
|
||||
) FILTER (WHERE hm.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
)
|
||||
FROM user_account ua
|
||||
JOIN tenant t ON t.id = ua.tenant_id AND t.status = 'active'
|
||||
LEFT JOIN household_member hm
|
||||
ON hm.user_id = ua.id AND hm.tenant_id = ua.tenant_id AND hm.status = 'active'
|
||||
LEFT JOIN household h
|
||||
ON h.id = hm.household_id AND h.tenant_id = hm.tenant_id AND h.status = 'active'
|
||||
WHERE ua.oidc_issuer = $1 AND ua.oidc_subject = $2 AND ua.status = 'active'
|
||||
GROUP BY ua.id, ua.public_id, t.public_id, t.name, t.policy_version, ua.display_name
|
||||
ORDER BY t.name`, principal.Issuer, principal.Subject)
|
||||
if err != nil {
|
||||
return CurrentUser{}, fmt.Errorf("query current user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
accounts := make([]Account, 0, 1)
|
||||
for rows.Next() {
|
||||
var account Account
|
||||
var membershipsJSON []byte
|
||||
if err := rows.Scan(
|
||||
&account.UserID,
|
||||
&account.TenantID,
|
||||
&account.TenantName,
|
||||
&account.PolicyVersion,
|
||||
&account.PlatformRole,
|
||||
&account.DisplayName,
|
||||
&membershipsJSON,
|
||||
); err != nil {
|
||||
return CurrentUser{}, fmt.Errorf("scan current user: %w", err)
|
||||
}
|
||||
var rawMemberships []struct {
|
||||
HouseholdID string `json:"household_id"`
|
||||
HouseholdName string `json:"household_name"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
if err := json.Unmarshal(membershipsJSON, &rawMemberships); err != nil {
|
||||
return CurrentUser{}, fmt.Errorf("decode household memberships: %w", err)
|
||||
}
|
||||
account.Memberships = make([]HouseholdMembership, 0, len(rawMemberships))
|
||||
for _, membership := range rawMemberships {
|
||||
account.Memberships = append(account.Memberships, HouseholdMembership{
|
||||
HouseholdID: membership.HouseholdID,
|
||||
HouseholdName: membership.HouseholdName,
|
||||
Role: membership.Role,
|
||||
Capabilities: CapabilitiesForRole(membership.Role),
|
||||
})
|
||||
}
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return CurrentUser{}, fmt.Errorf("iterate current user: %w", err)
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return CurrentUser{}, ErrAccountNotFound
|
||||
}
|
||||
|
||||
if _, err := r.pool.Exec(ctx, `
|
||||
UPDATE user_account SET last_login_at = now(), updated_at = now()
|
||||
WHERE oidc_issuer = $1 AND oidc_subject = $2 AND status = 'active'`,
|
||||
principal.Issuer, principal.Subject,
|
||||
); err != nil {
|
||||
return CurrentUser{}, fmt.Errorf("update last login: %w", err)
|
||||
}
|
||||
return CurrentUser{Identity: principal, Accounts: accounts}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) RequireHouseholdCapability(
|
||||
ctx context.Context,
|
||||
principal Principal,
|
||||
householdPublicID string,
|
||||
capability Capability,
|
||||
) (HouseholdAccess, error) {
|
||||
var access HouseholdAccess
|
||||
var role string
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT t.public_id::text, ua.public_id::text, h.public_id::text, hm.role
|
||||
FROM household h
|
||||
JOIN tenant t ON t.id = h.tenant_id AND t.status = 'active'
|
||||
JOIN household_member hm
|
||||
ON hm.household_id = h.id AND hm.tenant_id = h.tenant_id AND hm.status = 'active'
|
||||
JOIN user_account ua
|
||||
ON ua.id = hm.user_id AND ua.tenant_id = h.tenant_id AND ua.status = 'active'
|
||||
WHERE h.public_id = $1::uuid AND h.status = 'active'
|
||||
AND ua.oidc_issuer = $2 AND ua.oidc_subject = $3`,
|
||||
householdPublicID, principal.Issuer, principal.Subject,
|
||||
).Scan(&access.TenantID, &access.UserID, &access.HouseholdID, &role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return HouseholdAccess{}, ErrAccessDenied
|
||||
}
|
||||
if err != nil {
|
||||
return HouseholdAccess{}, fmt.Errorf("resolve household access: %w", err)
|
||||
}
|
||||
access.Role = Role(role)
|
||||
if !Can(access.Role, capability) {
|
||||
return HouseholdAccess{}, ErrAccessDenied
|
||||
}
|
||||
return access, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
localIssuer = "http://localhost:58180/realms/voice-local"
|
||||
ownerSubject = "00000000-0000-0000-0000-000000000101"
|
||||
outsiderSubject = "00000000-0000-0000-0000-000000000103"
|
||||
firstHouseholdID = "10000000-0000-0000-0000-000000000211"
|
||||
secondHouseholdID = "20000000-0000-0000-0000-000000000211"
|
||||
)
|
||||
|
||||
func TestRepositoryEnforcesTenantAndRoleBoundaries(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect integration database: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
repository := NewRepository(pool)
|
||||
|
||||
owner := Principal{Issuer: localIssuer, Subject: ownerSubject}
|
||||
currentUser, err := repository.CurrentUser(ctx, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("load owner: %v", err)
|
||||
}
|
||||
if len(currentUser.Accounts) != 1 || len(currentUser.Accounts[0].Memberships) != 1 {
|
||||
t.Fatalf("unexpected owner account: %#v", currentUser)
|
||||
}
|
||||
if currentUser.Accounts[0].Memberships[0].Role != RoleOwner {
|
||||
t.Fatalf("owner role not resolved: %#v", currentUser.Accounts[0].Memberships[0])
|
||||
}
|
||||
if _, err := repository.RequireHouseholdCapability(ctx, owner, firstHouseholdID, CapabilityHouseholdManage); err != nil {
|
||||
t.Fatalf("owner should manage own household: %v", err)
|
||||
}
|
||||
|
||||
outsider := Principal{Issuer: localIssuer, Subject: outsiderSubject}
|
||||
if _, err := repository.RequireHouseholdCapability(ctx, outsider, firstHouseholdID, CapabilityHouseholdRead); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("cross-tenant access error = %v, want ErrAccessDenied", err)
|
||||
}
|
||||
if _, err := repository.RequireHouseholdCapability(ctx, outsider, secondHouseholdID, CapabilityHouseholdRead); err != nil {
|
||||
t.Fatalf("viewer should read own household: %v", err)
|
||||
}
|
||||
if _, err := repository.RequireHouseholdCapability(ctx, outsider, secondHouseholdID, CapabilityHouseholdManage); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("viewer manage error = %v, want ErrAccessDenied", err)
|
||||
}
|
||||
if _, err := repository.RequireHouseholdCapability(ctx, owner, firstHouseholdID, CapabilityRecordingPlay); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("owner recording access error = %v, want ErrAccessDenied", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
)
|
||||
|
||||
var ErrInvalidToken = errors.New("invalid access token")
|
||||
|
||||
type TokenVerifier interface {
|
||||
Verify(context.Context, string) (Principal, error)
|
||||
}
|
||||
|
||||
type OIDCVerifier struct {
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
func NewOIDCVerifier(ctx context.Context, issuerURL, jwksURL, audience string) *OIDCVerifier {
|
||||
keySet := oidc.NewRemoteKeySet(ctx, jwksURL)
|
||||
return &OIDCVerifier{
|
||||
verifier: oidc.NewVerifier(issuerURL, keySet, &oidc.Config{ClientID: audience}),
|
||||
}
|
||||
}
|
||||
|
||||
func (v *OIDCVerifier) Verify(ctx context.Context, rawToken string) (Principal, error) {
|
||||
rawToken = strings.TrimSpace(rawToken)
|
||||
if rawToken == "" {
|
||||
return Principal{}, ErrInvalidToken
|
||||
}
|
||||
token, err := v.verifier.Verify(ctx, rawToken)
|
||||
if err != nil {
|
||||
return Principal{}, ErrInvalidToken
|
||||
}
|
||||
var claims struct {
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
}
|
||||
if err := token.Claims(&claims); err != nil {
|
||||
return Principal{}, ErrInvalidToken
|
||||
}
|
||||
if token.Issuer == "" || token.Subject == "" {
|
||||
return Principal{}, ErrInvalidToken
|
||||
}
|
||||
return Principal{
|
||||
Issuer: token.Issuer,
|
||||
Subject: token.Subject,
|
||||
DisplayName: claims.Name,
|
||||
PreferredUsername: claims.PreferredUsername,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
||||
)
|
||||
|
||||
func TestOIDCVerifierAcceptsSignedToken(t *testing.T) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyID := "test-key"
|
||||
webKey := jose.JSONWebKey{Key: &privateKey.PublicKey, KeyID: keyID, Algorithm: string(jose.RS256), Use: "sig"}
|
||||
keySet := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{webKey}}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(writer).Encode(keySet)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
issuer := "https://identity.example.test/realms/test"
|
||||
audience := "voice-platform-web"
|
||||
rawToken := signTestToken(t, privateKey, keyID, issuer, audience)
|
||||
verifier := NewOIDCVerifier(context.Background(), issuer, server.URL, audience)
|
||||
principal, err := verifier.Verify(context.Background(), rawToken)
|
||||
if err != nil {
|
||||
t.Fatalf("verify token: %v", err)
|
||||
}
|
||||
if principal.Issuer != issuer || principal.Subject != "user-123" || principal.PreferredUsername != "tester" {
|
||||
t.Fatalf("unexpected principal: %#v", principal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCVerifierRejectsEmptyAndWrongAudience(t *testing.T) {
|
||||
verifier := &OIDCVerifier{}
|
||||
if _, err := verifier.Verify(context.Background(), ""); err != ErrInvalidToken {
|
||||
t.Fatalf("empty token error = %v", err)
|
||||
}
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyID := "test-key"
|
||||
keySet := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{
|
||||
Key: &privateKey.PublicKey, KeyID: keyID, Algorithm: string(jose.RS256), Use: "sig",
|
||||
}}}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(writer).Encode(keySet)
|
||||
}))
|
||||
defer server.Close()
|
||||
issuer := "https://identity.example.test/realms/test"
|
||||
verifier = NewOIDCVerifier(context.Background(), issuer, server.URL, "expected-audience")
|
||||
if _, err := verifier.Verify(context.Background(), signTestToken(t, privateKey, keyID, issuer, "wrong-audience")); err != ErrInvalidToken {
|
||||
t.Fatalf("wrong audience error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func signTestToken(t *testing.T, key *rsa.PrivateKey, keyID, issuer, audience string) string {
|
||||
t.Helper()
|
||||
options := (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", keyID)
|
||||
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawToken, err := josejwt.Signed(signer).
|
||||
Claims(josejwt.Claims{
|
||||
Issuer: issuer, Subject: "user-123", Audience: josejwt.Audience{audience},
|
||||
Expiry: josejwt.NewNumericDate(time.Now().Add(time.Minute)),
|
||||
IssuedAt: josejwt.NewNumericDate(time.Now()),
|
||||
}).
|
||||
Claims(map[string]any{"name": "测试用户", "preferred_username": "tester"}).
|
||||
Serialize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rawToken
|
||||
}
|
||||
Reference in New Issue
Block a user