mirror of
https://github.com/tailscale/tailscale.git
synced 2026-09-15 23:42:48 -04:00
Package tailcfg defines the types and constants used by the Tailscale protocol, but since everything is all in one package, it’s difficult to sift through the docs: https://pkg.go.dev/tailscale.com/tailcfg We define and enumerate capabilities as string constants for tailcfg.NodeCapability and tailcfg.PeerCapability. This PR extracts them into their own packages: - tailcfg.CapabilityFileSharing becomes nodecap.FileSharing - tailcfg.NodeAttrOnlyTCP443 becomes nodecap.OnlyTCP443 - tailcfg.PeerCapabilityTaildrive becomes peercap.Taildrive We originally intended for CapabilityFoo to grant an entitlement or permission for Foo, and for NodeAttrBar to configure Bar in the nodeAttrs section of the policy file. However, there was no technical enforcement of this convention, so new capabilities have used the NodeAttr prefix regardless of meaning. Therefore, this PR unifies tailcfg.CapabilityFoo and tailcfg.NodeAttrBar into a single package as nodecap.Foo and nodecap.Bar. Ran `go fix -inline ./...` and committed the changes that replaced uses of the tailcfg aliases with the authoritative ones. Updates #20259 Change-Id: Ieb7e7e6c8247c39faf42fdf15c68cdc7c621c730 Signed-off-by: Simon Law <sfllaw@tailscale.com>
2072 lines
57 KiB
Go
2072 lines
57 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// Package main tests for tsidp focus on OAuth security boundaries and
|
|
// correct implementation of the OpenID Connect identity provider.
|
|
//
|
|
// Test Strategy:
|
|
// - Tests are intentionally granular to provide clear failure signals when
|
|
// security-critical logic breaks
|
|
// - OAuth flow tests cover both strict mode (registered clients only) and
|
|
// legacy mode (local funnel clients) to ensure proper access controls
|
|
// - Helper functions like normalizeMap ensure deterministic comparisons
|
|
// despite JSON marshaling order variations
|
|
// - The privateKey global is reused across tests for performance (RSA key
|
|
// generation is expensive)
|
|
|
|
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gopkg.in/square/go-jose.v2"
|
|
"gopkg.in/square/go-jose.v2/jwt"
|
|
"tailscale.com/client/local"
|
|
"tailscale.com/client/tailscale/apitype"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/tailcfg/peercap"
|
|
"tailscale.com/types/key"
|
|
"tailscale.com/types/opt"
|
|
"tailscale.com/types/views"
|
|
)
|
|
|
|
// normalizeMap recursively sorts []any values in a map[string]any to ensure
|
|
// deterministic test comparisons. This is necessary because JSON marshaling
|
|
// doesn't guarantee array order, and we need stable comparisons when testing
|
|
// claim merging and flattening logic.
|
|
func normalizeMap(t *testing.T, m map[string]any) map[string]any {
|
|
t.Helper()
|
|
normalized := make(map[string]any, len(m))
|
|
for k, v := range m {
|
|
switch val := v.(type) {
|
|
case []any:
|
|
sorted := make([]string, len(val))
|
|
for i, item := range val {
|
|
sorted[i] = fmt.Sprintf("%v", item) // convert everything to string for sorting
|
|
}
|
|
sort.Strings(sorted)
|
|
|
|
// convert back to []any
|
|
sortedIface := make([]any, len(sorted))
|
|
for i, s := range sorted {
|
|
sortedIface[i] = s
|
|
}
|
|
normalized[k] = sortedIface
|
|
|
|
default:
|
|
normalized[k] = v
|
|
}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func mustMarshalJSON(t *testing.T, v any) tailcfg.RawMessage {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return tailcfg.RawMessage(b)
|
|
}
|
|
|
|
// privateKey is a shared RSA private key used across tests. It's lazily
|
|
// initialized on first use to avoid the expensive key generation cost
|
|
// for every test. Protected by privateKeyMu for thread safety.
|
|
var (
|
|
privateKey *rsa.PrivateKey
|
|
privateKeyMu sync.Mutex
|
|
)
|
|
|
|
func oidcTestingSigner(t *testing.T) jose.Signer {
|
|
t.Helper()
|
|
privKey := mustGeneratePrivateKey(t)
|
|
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: privKey}, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create signer: %v", err)
|
|
}
|
|
return sig
|
|
}
|
|
|
|
func oidcTestingPublicKey(t *testing.T) *rsa.PublicKey {
|
|
t.Helper()
|
|
privKey := mustGeneratePrivateKey(t)
|
|
return &privKey.PublicKey
|
|
}
|
|
|
|
func mustGeneratePrivateKey(t *testing.T) *rsa.PrivateKey {
|
|
t.Helper()
|
|
privateKeyMu.Lock()
|
|
defer privateKeyMu.Unlock()
|
|
|
|
if privateKey != nil {
|
|
return privateKey
|
|
}
|
|
|
|
var err error
|
|
privateKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate key: %v", err)
|
|
}
|
|
|
|
return privateKey
|
|
}
|
|
|
|
func TestFlattenExtraClaims(t *testing.T) {
|
|
log.SetOutput(io.Discard) // suppress log output during tests
|
|
|
|
tests := []struct {
|
|
name string
|
|
input []capRule
|
|
expected map[string]any
|
|
}{
|
|
{
|
|
name: "empty-extra-claims",
|
|
input: []capRule{
|
|
{ExtraClaims: map[string]any{}},
|
|
},
|
|
expected: map[string]any{},
|
|
},
|
|
{
|
|
name: "string-and-number-values",
|
|
input: []capRule{
|
|
{
|
|
ExtraClaims: map[string]any{
|
|
"featureA": "read",
|
|
"featureB": 42,
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"featureA": "read",
|
|
"featureB": "42",
|
|
},
|
|
},
|
|
{
|
|
name: "slice-of-strings-and-ints",
|
|
input: []capRule{
|
|
{
|
|
ExtraClaims: map[string]any{
|
|
"roles": []any{"admin", "user", 1},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"roles": []any{"admin", "user", "1"},
|
|
},
|
|
},
|
|
{
|
|
// duplicate values deduplicated via slice input
|
|
name: "dedup-slice-input",
|
|
input: []capRule{
|
|
{
|
|
ExtraClaims: map[string]any{
|
|
"foo": []string{"bar", "baz"},
|
|
},
|
|
},
|
|
{
|
|
ExtraClaims: map[string]any{
|
|
"foo": []any{"bar", "qux"},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"foo": []any{"bar", "baz", "qux"},
|
|
},
|
|
},
|
|
{
|
|
// ignore unsupported map type, keep valid scalar
|
|
name: "ignore-unsupported-map-keep-scalar",
|
|
input: []capRule{
|
|
{
|
|
ExtraClaims: map[string]any{
|
|
"invalid": map[string]any{"bad": "yes"},
|
|
"valid": "ok",
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"valid": "ok",
|
|
},
|
|
},
|
|
{
|
|
name: "scalar-first-slice-second",
|
|
input: []capRule{
|
|
{ExtraClaims: map[string]any{"foo": "bar"}},
|
|
{ExtraClaims: map[string]any{"foo": []any{"baz"}}},
|
|
},
|
|
expected: map[string]any{
|
|
"foo": []any{"bar", "baz"}, // converts to slice when any rule provides a slice
|
|
},
|
|
},
|
|
{
|
|
name: "conflicting-scalar-and-unsupported-map",
|
|
input: []capRule{
|
|
{ExtraClaims: map[string]any{"foo": "bar"}},
|
|
{ExtraClaims: map[string]any{"foo": map[string]any{"bad": "entry"}}},
|
|
},
|
|
expected: map[string]any{
|
|
"foo": "bar", // map should be ignored
|
|
},
|
|
},
|
|
{
|
|
name: "multiple-slices-with-overlap",
|
|
input: []capRule{
|
|
{ExtraClaims: map[string]any{"roles": []any{"admin", "user"}}},
|
|
{ExtraClaims: map[string]any{"roles": []any{"admin", "guest"}}},
|
|
},
|
|
expected: map[string]any{
|
|
"roles": []any{"admin", "user", "guest"},
|
|
},
|
|
},
|
|
{
|
|
name: "slice-with-unsupported-values",
|
|
input: []capRule{
|
|
{ExtraClaims: map[string]any{
|
|
"mixed": []any{"ok", 42, map[string]string{"oops": "fail"}},
|
|
}},
|
|
},
|
|
expected: map[string]any{
|
|
"mixed": []any{"ok", "42"}, // map is ignored
|
|
},
|
|
},
|
|
{
|
|
name: "duplicate-scalar-value",
|
|
input: []capRule{
|
|
{ExtraClaims: map[string]any{"env": "prod"}},
|
|
{ExtraClaims: map[string]any{"env": "prod"}},
|
|
},
|
|
expected: map[string]any{
|
|
"env": "prod", // not converted to slice
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := flattenExtraClaims(tt.input)
|
|
|
|
gotNormalized := normalizeMap(t, got)
|
|
expectedNormalized := normalizeMap(t, tt.expected)
|
|
|
|
if !reflect.DeepEqual(gotNormalized, expectedNormalized) {
|
|
t.Errorf("mismatch\nGot:\n%s\nWant:\n%s", gotNormalized, expectedNormalized)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraClaims(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
claim tailscaleClaims
|
|
extraClaims []capRule
|
|
expected map[string]any
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "extra-claim",
|
|
claim: tailscaleClaims{
|
|
Claims: jwt.Claims{},
|
|
Nonce: "foobar",
|
|
Key: key.NodePublic{},
|
|
Addresses: views.Slice[netip.Prefix]{},
|
|
NodeID: 0,
|
|
NodeName: "test-node",
|
|
Tailnet: "test.ts.net",
|
|
Email: "test@example.com",
|
|
UserID: 0,
|
|
UserName: "test",
|
|
},
|
|
extraClaims: []capRule{
|
|
{
|
|
ExtraClaims: map[string]any{
|
|
"foo": []string{"bar"},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"nonce": "foobar",
|
|
"key": "nodekey:0000000000000000000000000000000000000000000000000000000000000000",
|
|
"addresses": nil,
|
|
"nid": float64(0),
|
|
"node": "test-node",
|
|
"tailnet": "test.ts.net",
|
|
"email": "test@example.com",
|
|
"username": "test",
|
|
"foo": []any{"bar"},
|
|
},
|
|
},
|
|
{
|
|
name: "duplicate-claim-distinct-values",
|
|
claim: tailscaleClaims{
|
|
Claims: jwt.Claims{},
|
|
Nonce: "foobar",
|
|
Key: key.NodePublic{},
|
|
Addresses: views.Slice[netip.Prefix]{},
|
|
NodeID: 0,
|
|
NodeName: "test-node",
|
|
Tailnet: "test.ts.net",
|
|
Email: "test@example.com",
|
|
UserID: 0,
|
|
UserName: "test",
|
|
},
|
|
extraClaims: []capRule{
|
|
{
|
|
ExtraClaims: map[string]any{
|
|
"foo": []string{"bar"},
|
|
},
|
|
},
|
|
{
|
|
ExtraClaims: map[string]any{
|
|
"foo": []string{"foobar"},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"nonce": "foobar",
|
|
"key": "nodekey:0000000000000000000000000000000000000000000000000000000000000000",
|
|
"addresses": nil,
|
|
"nid": float64(0),
|
|
"node": "test-node",
|
|
"tailnet": "test.ts.net",
|
|
"email": "test@example.com",
|
|
"username": "test",
|
|
"foo": []any{"foobar", "bar"},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple-extra-claims",
|
|
claim: tailscaleClaims{
|
|
Claims: jwt.Claims{},
|
|
Nonce: "foobar",
|
|
Key: key.NodePublic{},
|
|
Addresses: views.Slice[netip.Prefix]{},
|
|
NodeID: 0,
|
|
NodeName: "test-node",
|
|
Tailnet: "test.ts.net",
|
|
Email: "test@example.com",
|
|
UserID: 0,
|
|
UserName: "test",
|
|
},
|
|
extraClaims: []capRule{
|
|
{
|
|
ExtraClaims: map[string]any{
|
|
"foo": []string{"bar"},
|
|
},
|
|
},
|
|
{
|
|
ExtraClaims: map[string]any{
|
|
"bar": []string{"foo"},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"nonce": "foobar",
|
|
"key": "nodekey:0000000000000000000000000000000000000000000000000000000000000000",
|
|
"addresses": nil,
|
|
"nid": float64(0),
|
|
"node": "test-node",
|
|
"tailnet": "test.ts.net",
|
|
"email": "test@example.com",
|
|
"username": "test",
|
|
"foo": []any{"bar"},
|
|
"bar": []any{"foo"},
|
|
},
|
|
},
|
|
{
|
|
name: "overwrite-claim",
|
|
claim: tailscaleClaims{
|
|
Claims: jwt.Claims{},
|
|
Nonce: "foobar",
|
|
Key: key.NodePublic{},
|
|
Addresses: views.Slice[netip.Prefix]{},
|
|
NodeID: 0,
|
|
NodeName: "test-node",
|
|
Tailnet: "test.ts.net",
|
|
Email: "test@example.com",
|
|
UserID: 0,
|
|
UserName: "test",
|
|
},
|
|
extraClaims: []capRule{
|
|
{
|
|
ExtraClaims: map[string]any{
|
|
"username": "foobar",
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"nonce": "foobar",
|
|
"key": "nodekey:0000000000000000000000000000000000000000000000000000000000000000",
|
|
"addresses": nil,
|
|
"nid": float64(0),
|
|
"node": "test-node",
|
|
"tailnet": "test.ts.net",
|
|
"email": "test@example.com",
|
|
"username": "foobar",
|
|
},
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "empty-extra-claims",
|
|
claim: tailscaleClaims{
|
|
Claims: jwt.Claims{},
|
|
Nonce: "foobar",
|
|
Key: key.NodePublic{},
|
|
Addresses: views.Slice[netip.Prefix]{},
|
|
NodeID: 0,
|
|
NodeName: "test-node",
|
|
Tailnet: "test.ts.net",
|
|
Email: "test@example.com",
|
|
UserID: 0,
|
|
UserName: "test",
|
|
},
|
|
extraClaims: []capRule{{ExtraClaims: map[string]any{}}},
|
|
expected: map[string]any{
|
|
"nonce": "foobar",
|
|
"key": "nodekey:0000000000000000000000000000000000000000000000000000000000000000",
|
|
"addresses": nil,
|
|
"nid": float64(0),
|
|
"node": "test-node",
|
|
"tailnet": "test.ts.net",
|
|
"email": "test@example.com",
|
|
"username": "test",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
claims, err := withExtraClaims(tt.claim, tt.extraClaims)
|
|
if err != nil && !tt.expectError {
|
|
t.Fatalf("claim.withExtraClaims() unexpected error = %v", err)
|
|
} else if err == nil && tt.expectError {
|
|
t.Fatalf("expected error, got nil")
|
|
} else if err != nil && tt.expectError {
|
|
return // just as expected
|
|
}
|
|
|
|
// Marshal to JSON then unmarshal back to map[string]any
|
|
gotClaims, err := json.Marshal(claims)
|
|
if err != nil {
|
|
t.Errorf("json.Marshal(claims) error = %v", err)
|
|
}
|
|
|
|
var gotClaimsMap map[string]any
|
|
if err := json.Unmarshal(gotClaims, &gotClaimsMap); err != nil {
|
|
t.Fatalf("json.Unmarshal(gotClaims) error = %v", err)
|
|
}
|
|
|
|
gotNormalized := normalizeMap(t, gotClaimsMap)
|
|
expectedNormalized := normalizeMap(t, tt.expected)
|
|
|
|
if !reflect.DeepEqual(gotNormalized, expectedNormalized) {
|
|
t.Errorf("claims mismatch:\n got: %#v\nwant: %#v", gotNormalized, expectedNormalized)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServeToken(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
caps tailcfg.PeerCapMap
|
|
method string
|
|
grantType string
|
|
code string
|
|
omitCode bool
|
|
redirectURI string
|
|
remoteAddr string
|
|
strictMode bool
|
|
expectError bool
|
|
expected map[string]any
|
|
}{
|
|
{
|
|
name: "GET-not-allowed",
|
|
method: "GET",
|
|
grantType: "authorization_code",
|
|
strictMode: false,
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "unsupported-grant-type",
|
|
method: "POST",
|
|
grantType: "pkcs",
|
|
strictMode: false,
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "invalid-code",
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
code: "invalid-code",
|
|
strictMode: false,
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "omit-code-from-form",
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
omitCode: true,
|
|
strictMode: false,
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "invalid-redirect-uri",
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
code: "valid-code",
|
|
redirectURI: "https://invalid.example.com/callback",
|
|
remoteAddr: "127.0.0.1:12345",
|
|
strictMode: false,
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "invalid-remoteAddr",
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
code: "valid-code",
|
|
remoteAddr: "192.168.0.1:12345",
|
|
strictMode: false,
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "extra-claim-included-non-strict",
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
code: "valid-code",
|
|
remoteAddr: "127.0.0.1:12345",
|
|
strictMode: false,
|
|
caps: tailcfg.PeerCapMap{
|
|
peercap.TsIDP: {
|
|
mustMarshalJSON(t, capRule{
|
|
IncludeInUserInfo: true,
|
|
ExtraClaims: map[string]any{
|
|
"foo": "bar",
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"foo": "bar",
|
|
},
|
|
},
|
|
{
|
|
// attempt to overwrite protected claim in non-strict mode
|
|
name: "overwrite-protected-claim-non-strict",
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
code: "valid-code",
|
|
strictMode: false,
|
|
caps: tailcfg.PeerCapMap{
|
|
peercap.TsIDP: {
|
|
mustMarshalJSON(t, capRule{
|
|
IncludeInUserInfo: true,
|
|
ExtraClaims: map[string]any{
|
|
"sub": "should-not-overwrite",
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
// Use setupTestServer helper
|
|
s := setupTestServer(t, tt.strictMode)
|
|
|
|
// Fake user/node
|
|
profile := &tailcfg.UserProfile{
|
|
LoginName: "alice@example.com",
|
|
DisplayName: "Alice Example",
|
|
ProfilePicURL: "https://example.com/alice.jpg",
|
|
}
|
|
node := &tailcfg.Node{
|
|
ID: 123,
|
|
Name: "test-node.test.ts.net.",
|
|
User: 456,
|
|
Key: key.NodePublic{},
|
|
Cap: 1,
|
|
DiscoKey: key.DiscoPublic{},
|
|
}
|
|
|
|
remoteUser := &apitype.WhoIsResponse{
|
|
Node: node,
|
|
UserProfile: profile,
|
|
CapMap: tt.caps,
|
|
}
|
|
|
|
// Setup auth request with appropriate configuration for strict mode
|
|
var funnelClientPtr *funnelClient
|
|
if tt.strictMode {
|
|
funnelClientPtr = &funnelClient{
|
|
ID: "client-id",
|
|
Secret: "test-secret",
|
|
Name: "Test Client",
|
|
RedirectURI: "https://rp.example.com/callback",
|
|
}
|
|
s.funnelClients["client-id"] = funnelClientPtr
|
|
}
|
|
|
|
s.code["valid-code"] = &authRequest{
|
|
clientID: "client-id",
|
|
nonce: "nonce123",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
validTill: now.Add(5 * time.Minute),
|
|
remoteUser: remoteUser,
|
|
localRP: !tt.strictMode,
|
|
funnelRP: funnelClientPtr,
|
|
}
|
|
|
|
form := url.Values{}
|
|
form.Set("grant_type", tt.grantType)
|
|
form.Set("redirect_uri", tt.redirectURI)
|
|
if !tt.omitCode {
|
|
form.Set("code", tt.code)
|
|
}
|
|
// Add client credentials for strict mode
|
|
if tt.strictMode {
|
|
form.Set("client_id", "client-id")
|
|
form.Set("client_secret", "test-secret")
|
|
}
|
|
|
|
req := httptest.NewRequest(tt.method, "/token", strings.NewReader(form.Encode()))
|
|
req.RemoteAddr = tt.remoteAddr
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rr := httptest.NewRecorder()
|
|
|
|
s.serveToken(rr, req)
|
|
|
|
if tt.expectError {
|
|
if rr.Code == http.StatusOK {
|
|
t.Fatalf("expected error, got 200 OK: %s", rr.Body.String())
|
|
}
|
|
return
|
|
}
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 OK, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
IDToken string `json:"id_token"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
tok, err := jwt.ParseSigned(resp.IDToken)
|
|
if err != nil {
|
|
t.Fatalf("failed to parse ID token: %v", err)
|
|
}
|
|
|
|
out := make(map[string]any)
|
|
if err := tok.Claims(oidcTestingPublicKey(t), &out); err != nil {
|
|
t.Fatalf("failed to extract claims: %v", err)
|
|
}
|
|
|
|
for k, want := range tt.expected {
|
|
got, ok := out[k]
|
|
if !ok {
|
|
t.Errorf("missing expected claim %q", k)
|
|
continue
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("claim %q: got %v, want %v", k, got, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraUserInfo(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
caps tailcfg.PeerCapMap
|
|
tokenValidTill time.Time
|
|
expected map[string]any
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "extra-claim",
|
|
tokenValidTill: time.Now().Add(1 * time.Minute),
|
|
caps: tailcfg.PeerCapMap{
|
|
peercap.TsIDP: {
|
|
mustMarshalJSON(t, capRule{
|
|
IncludeInUserInfo: true,
|
|
ExtraClaims: map[string]any{
|
|
"foo": []string{"bar"},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"foo": []any{"bar"},
|
|
},
|
|
},
|
|
{
|
|
name: "duplicate-claim-distinct-values",
|
|
tokenValidTill: time.Now().Add(1 * time.Minute),
|
|
caps: tailcfg.PeerCapMap{
|
|
peercap.TsIDP: {
|
|
mustMarshalJSON(t, capRule{
|
|
IncludeInUserInfo: true,
|
|
ExtraClaims: map[string]any{
|
|
"foo": []string{"bar", "foobar"},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"foo": []any{"bar", "foobar"},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple-extra-claims",
|
|
tokenValidTill: time.Now().Add(1 * time.Minute),
|
|
caps: tailcfg.PeerCapMap{
|
|
peercap.TsIDP: {
|
|
mustMarshalJSON(t, capRule{
|
|
IncludeInUserInfo: true,
|
|
ExtraClaims: map[string]any{
|
|
"foo": "bar",
|
|
"bar": "foo",
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
expected: map[string]any{
|
|
"foo": "bar",
|
|
"bar": "foo",
|
|
},
|
|
},
|
|
{
|
|
name: "empty-extra-claims",
|
|
caps: tailcfg.PeerCapMap{},
|
|
tokenValidTill: time.Now().Add(1 * time.Minute),
|
|
expected: map[string]any{},
|
|
},
|
|
{
|
|
name: "overwrite-protected-claim",
|
|
tokenValidTill: time.Now().Add(1 * time.Minute),
|
|
caps: tailcfg.PeerCapMap{
|
|
peercap.TsIDP: {
|
|
mustMarshalJSON(t, capRule{
|
|
IncludeInUserInfo: true,
|
|
ExtraClaims: map[string]any{
|
|
"sub": "should-not-overwrite",
|
|
"foo": "ok",
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "extra-claim-omitted",
|
|
tokenValidTill: time.Now().Add(1 * time.Minute),
|
|
caps: tailcfg.PeerCapMap{
|
|
peercap.TsIDP: {
|
|
mustMarshalJSON(t, capRule{
|
|
IncludeInUserInfo: false,
|
|
ExtraClaims: map[string]any{
|
|
"foo": "ok",
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
expected: map[string]any{},
|
|
},
|
|
{
|
|
name: "expired-token",
|
|
caps: tailcfg.PeerCapMap{},
|
|
tokenValidTill: time.Now().Add(-1 * time.Minute),
|
|
expected: map[string]any{},
|
|
expectError: true,
|
|
},
|
|
}
|
|
token := "valid-token"
|
|
|
|
// Create a fake tailscale Node
|
|
node := &tailcfg.Node{
|
|
ID: 123,
|
|
Name: "test-node.test.ts.net.",
|
|
User: 456,
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
// Construct the remote user
|
|
profile := tailcfg.UserProfile{
|
|
LoginName: "alice@example.com",
|
|
DisplayName: "Alice Example",
|
|
ProfilePicURL: "https://example.com/alice.jpg",
|
|
}
|
|
|
|
remoteUser := &apitype.WhoIsResponse{
|
|
Node: node,
|
|
UserProfile: &profile,
|
|
CapMap: tt.caps,
|
|
}
|
|
|
|
// Insert a valid token into the idpServer
|
|
s := &idpServer{
|
|
allowInsecureRegistration: true, // Default to allowing insecure registration for backward compatibility
|
|
accessToken: map[string]*authRequest{
|
|
token: {
|
|
validTill: tt.tokenValidTill,
|
|
remoteUser: remoteUser,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Construct request
|
|
req := httptest.NewRequest("GET", "/userinfo", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rr := httptest.NewRecorder()
|
|
|
|
// Call the method under test
|
|
s.serveUserInfo(rr, req)
|
|
|
|
if tt.expectError {
|
|
if rr.Code == http.StatusOK {
|
|
t.Fatalf("expected error, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
return
|
|
}
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 OK, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to parse JSON response: %v", err)
|
|
}
|
|
|
|
// Construct expected
|
|
tt.expected["sub"] = remoteUser.Node.User.String()
|
|
tt.expected["name"] = profile.DisplayName
|
|
tt.expected["email"] = profile.LoginName
|
|
tt.expected["picture"] = profile.ProfilePicURL
|
|
tt.expected["username"], _, _ = strings.Cut(profile.LoginName, "@")
|
|
|
|
gotNormalized := normalizeMap(t, resp)
|
|
expectedNormalized := normalizeMap(t, tt.expected)
|
|
|
|
if !reflect.DeepEqual(gotNormalized, expectedNormalized) {
|
|
t.Errorf("UserInfo mismatch:\n got: %#v\nwant: %#v", gotNormalized, expectedNormalized)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFunnelClientsPersistence(t *testing.T) {
|
|
testClients := map[string]*funnelClient{
|
|
"test-client-1": {
|
|
ID: "test-client-1",
|
|
Secret: "test-secret-1",
|
|
Name: "Test Client 1",
|
|
RedirectURI: "https://example.com/callback",
|
|
},
|
|
"test-client-2": {
|
|
ID: "test-client-2",
|
|
Secret: "test-secret-2",
|
|
Name: "Test Client 2",
|
|
RedirectURI: "https://example2.com/callback",
|
|
},
|
|
}
|
|
|
|
testData, err := json.Marshal(testClients)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal test data: %v", err)
|
|
}
|
|
|
|
tmpFile := t.TempDir() + "/oidc-funnel-clients.json"
|
|
if err := os.WriteFile(tmpFile, testData, 0600); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
t.Run("load_from_existing_file", func(t *testing.T) {
|
|
srv := &idpServer{}
|
|
|
|
// Simulate the funnel clients loading logic from main()
|
|
srv.funnelClients = make(map[string]*funnelClient)
|
|
f, err := os.Open(tmpFile)
|
|
if err == nil {
|
|
if err := json.NewDecoder(f).Decode(&srv.funnelClients); err != nil {
|
|
t.Fatalf("could not parse %s: %v", tmpFile, err)
|
|
}
|
|
f.Close()
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("could not open %s: %v", tmpFile, err)
|
|
}
|
|
|
|
// Verify clients were loaded correctly
|
|
if len(srv.funnelClients) != 2 {
|
|
t.Errorf("expected 2 clients, got %d", len(srv.funnelClients))
|
|
}
|
|
|
|
client1, ok := srv.funnelClients["test-client-1"]
|
|
if !ok {
|
|
t.Error("expected test-client-1 to be loaded")
|
|
} else {
|
|
if client1.Name != "Test Client 1" {
|
|
t.Errorf("expected client name 'Test Client 1', got '%s'", client1.Name)
|
|
}
|
|
if client1.Secret != "test-secret-1" {
|
|
t.Errorf("expected client secret 'test-secret-1', got '%s'", client1.Secret)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("initialize_empty_when_no_file", func(t *testing.T) {
|
|
nonExistentFile := t.TempDir() + "/non-existent.json"
|
|
|
|
srv := &idpServer{}
|
|
|
|
// Simulate the funnel clients loading logic from main()
|
|
srv.funnelClients = make(map[string]*funnelClient)
|
|
f, err := os.Open(nonExistentFile)
|
|
if err == nil {
|
|
if err := json.NewDecoder(f).Decode(&srv.funnelClients); err != nil {
|
|
t.Fatalf("could not parse %s: %v", nonExistentFile, err)
|
|
}
|
|
f.Close()
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("could not open %s: %v", nonExistentFile, err)
|
|
}
|
|
|
|
// Verify map is initialized but empty
|
|
if srv.funnelClients == nil {
|
|
t.Error("expected funnelClients map to be initialized")
|
|
}
|
|
if len(srv.funnelClients) != 0 {
|
|
t.Errorf("expected empty map, got %d clients", len(srv.funnelClients))
|
|
}
|
|
})
|
|
|
|
t.Run("persist_and_reload_clients", func(t *testing.T) {
|
|
tmpFile2 := t.TempDir() + "/test-persistence.json"
|
|
|
|
// Create initial server with one client
|
|
srv1 := &idpServer{
|
|
funnelClients: make(map[string]*funnelClient),
|
|
}
|
|
srv1.funnelClients["new-client"] = &funnelClient{
|
|
ID: "new-client",
|
|
Secret: "new-secret",
|
|
Name: "New Client",
|
|
RedirectURI: "https://new.example.com/callback",
|
|
}
|
|
|
|
// Save clients to file (simulating saveFunnelClients)
|
|
data, err := json.Marshal(srv1.funnelClients)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal clients: %v", err)
|
|
}
|
|
if err := os.WriteFile(tmpFile2, data, 0600); err != nil {
|
|
t.Fatalf("failed to write clients file: %v", err)
|
|
}
|
|
|
|
// Create new server instance and load clients
|
|
srv2 := &idpServer{}
|
|
srv2.funnelClients = make(map[string]*funnelClient)
|
|
f, err := os.Open(tmpFile2)
|
|
if err == nil {
|
|
if err := json.NewDecoder(f).Decode(&srv2.funnelClients); err != nil {
|
|
t.Fatalf("could not parse %s: %v", tmpFile2, err)
|
|
}
|
|
f.Close()
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("could not open %s: %v", tmpFile2, err)
|
|
}
|
|
|
|
// Verify the client was persisted correctly
|
|
loadedClient, ok := srv2.funnelClients["new-client"]
|
|
if !ok {
|
|
t.Error("expected new-client to be loaded after persistence")
|
|
} else {
|
|
if loadedClient.Name != "New Client" {
|
|
t.Errorf("expected client name 'New Client', got '%s'", loadedClient.Name)
|
|
}
|
|
if loadedClient.Secret != "new-secret" {
|
|
t.Errorf("expected client secret 'new-secret', got '%s'", loadedClient.Secret)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("strict_mode_file_handling", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Test strict mode uses oauth-clients.json
|
|
srv1 := setupTestServer(t, true)
|
|
srv1.rootPath = tmpDir
|
|
srv1.funnelClients["oauth-client"] = &funnelClient{
|
|
ID: "oauth-client",
|
|
Secret: "oauth-secret",
|
|
Name: "OAuth Client",
|
|
RedirectURI: "https://oauth.example.com/callback",
|
|
}
|
|
|
|
// Test storeFunnelClientsLocked in strict mode
|
|
srv1.mu.Lock()
|
|
err := srv1.storeFunnelClientsLocked()
|
|
srv1.mu.Unlock()
|
|
|
|
if err != nil {
|
|
t.Fatalf("failed to store clients in strict mode: %v", err)
|
|
}
|
|
|
|
// Verify oauth-clients.json was created
|
|
oauthPath := tmpDir + "/" + oauthClientsFile
|
|
if _, err := os.Stat(oauthPath); err != nil {
|
|
t.Errorf("expected oauth-clients.json to be created: %v", err)
|
|
}
|
|
|
|
// Verify oidc-funnel-clients.json was NOT created
|
|
funnelPath := tmpDir + "/" + funnelClientsFile
|
|
if _, err := os.Stat(funnelPath); !os.IsNotExist(err) {
|
|
t.Error("expected oidc-funnel-clients.json NOT to be created in strict mode")
|
|
}
|
|
})
|
|
|
|
t.Run("non_strict_mode_file_handling", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Test non-strict mode uses oidc-funnel-clients.json
|
|
srv1 := setupTestServer(t, false)
|
|
srv1.rootPath = tmpDir
|
|
srv1.funnelClients["funnel-client"] = &funnelClient{
|
|
ID: "funnel-client",
|
|
Secret: "funnel-secret",
|
|
Name: "Funnel Client",
|
|
RedirectURI: "https://funnel.example.com/callback",
|
|
}
|
|
|
|
// Test storeFunnelClientsLocked in non-strict mode
|
|
srv1.mu.Lock()
|
|
err := srv1.storeFunnelClientsLocked()
|
|
srv1.mu.Unlock()
|
|
|
|
if err != nil {
|
|
t.Fatalf("failed to store clients in non-strict mode: %v", err)
|
|
}
|
|
|
|
// Verify oidc-funnel-clients.json was created
|
|
funnelPath := tmpDir + "/" + funnelClientsFile
|
|
if _, err := os.Stat(funnelPath); err != nil {
|
|
t.Errorf("expected oidc-funnel-clients.json to be created: %v", err)
|
|
}
|
|
|
|
// Verify oauth-clients.json was NOT created
|
|
oauthPath := tmpDir + "/" + oauthClientsFile
|
|
if _, err := os.Stat(oauthPath); !os.IsNotExist(err) {
|
|
t.Error("expected oauth-clients.json NOT to be created in non-strict mode")
|
|
}
|
|
})
|
|
}
|
|
|
|
// Test helper functions for strict OAuth mode testing
|
|
func setupTestServer(t *testing.T, strictMode bool) *idpServer {
|
|
return setupTestServerWithClient(t, strictMode, nil)
|
|
}
|
|
|
|
// setupTestServerWithClient creates a test server with an optional LocalClient.
|
|
// If lc is nil, the server will have no LocalClient (original behavior).
|
|
// If lc is provided, it will be used for WhoIs calls during testing.
|
|
func setupTestServerWithClient(t *testing.T, strictMode bool, lc *local.Client) *idpServer {
|
|
t.Helper()
|
|
|
|
srv := &idpServer{
|
|
allowInsecureRegistration: !strictMode,
|
|
code: make(map[string]*authRequest),
|
|
accessToken: make(map[string]*authRequest),
|
|
funnelClients: make(map[string]*funnelClient),
|
|
serverURL: "https://test.ts.net",
|
|
rootPath: t.TempDir(),
|
|
lc: lc,
|
|
}
|
|
|
|
// Add a test client for funnel/strict mode testing
|
|
srv.funnelClients["test-client"] = &funnelClient{
|
|
ID: "test-client",
|
|
Secret: "test-secret",
|
|
Name: "Test Client",
|
|
RedirectURI: "https://rp.example.com/callback",
|
|
}
|
|
|
|
// Inject a working signer for token tests
|
|
srv.lazySigner.Set(oidcTestingSigner(t))
|
|
|
|
return srv
|
|
}
|
|
|
|
func TestGetAllowInsecureRegistration(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
flagSet bool
|
|
flagValue bool
|
|
expectAllowInsecureRegistration bool
|
|
}{
|
|
{
|
|
// flag explicitly set to false - insecure registration disabled (strict mode)
|
|
name: "flag-false-insecure-disabled",
|
|
flagSet: true,
|
|
flagValue: false,
|
|
expectAllowInsecureRegistration: false,
|
|
},
|
|
{
|
|
// flag explicitly set to true - insecure registration enabled
|
|
name: "flag-true-insecure-enabled",
|
|
flagSet: true,
|
|
flagValue: true,
|
|
expectAllowInsecureRegistration: true,
|
|
},
|
|
{
|
|
// flag unset - insecure registration enabled (default for backward compatibility)
|
|
name: "flag-unset-insecure-enabled-default",
|
|
flagSet: false,
|
|
flagValue: false, // not used when unset
|
|
expectAllowInsecureRegistration: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Save original state
|
|
originalFlag := flagAllowInsecureRegistration
|
|
defer func() {
|
|
flagAllowInsecureRegistration = originalFlag
|
|
}()
|
|
|
|
// Set up test state by creating a new BoolFlag and setting values
|
|
var b opt.Bool
|
|
flagAllowInsecureRegistration = opt.BoolFlag{Bool: &b}
|
|
if tt.flagSet {
|
|
flagAllowInsecureRegistration.Bool.Set(tt.flagValue)
|
|
}
|
|
// Note: when tt.flagSet is false, the Bool remains unset (which is what we want)
|
|
|
|
got := getAllowInsecureRegistration()
|
|
if got != tt.expectAllowInsecureRegistration {
|
|
t.Errorf("getAllowInsecureRegistration() = %v, want %v", got, tt.expectAllowInsecureRegistration)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMigrateOAuthClients verifies the migration from legacy funnel clients
|
|
// to OAuth clients. This migration is necessary when transitioning from
|
|
// non-strict to strict OAuth mode. The migration logic should:
|
|
// - Copy clients from oidc-funnel-clients.json to oauth-clients.json
|
|
// - Rename the old file to mark it as deprecated
|
|
// - Handle cases where files already exist or are missing
|
|
func TestMigrateOAuthClients(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setupOldFile bool
|
|
setupNewFile bool
|
|
oldFileContent map[string]*funnelClient
|
|
newFileContent map[string]*funnelClient
|
|
expectError bool
|
|
expectNewFileExists bool
|
|
expectOldRenamed bool
|
|
}{
|
|
{
|
|
name: "migrate-old-to-new",
|
|
setupOldFile: true,
|
|
oldFileContent: map[string]*funnelClient{
|
|
"old-client": {
|
|
ID: "old-client",
|
|
Secret: "old-secret",
|
|
Name: "Old Client",
|
|
RedirectURI: "https://old.example.com/callback",
|
|
},
|
|
},
|
|
expectNewFileExists: true,
|
|
expectOldRenamed: true,
|
|
},
|
|
{
|
|
name: "new-file-exists-no-migration",
|
|
setupNewFile: true,
|
|
newFileContent: map[string]*funnelClient{
|
|
"existing-client": {
|
|
ID: "existing-client",
|
|
Secret: "existing-secret",
|
|
Name: "Existing Client",
|
|
RedirectURI: "https://existing.example.com/callback",
|
|
},
|
|
},
|
|
expectNewFileExists: true,
|
|
expectOldRenamed: false,
|
|
},
|
|
{
|
|
name: "neither-exists-create-empty",
|
|
expectNewFileExists: true,
|
|
expectOldRenamed: false,
|
|
},
|
|
{
|
|
name: "both-exist-prefer-new",
|
|
setupOldFile: true,
|
|
setupNewFile: true,
|
|
oldFileContent: map[string]*funnelClient{
|
|
"old-client": {
|
|
ID: "old-client",
|
|
Secret: "old-secret",
|
|
Name: "Old Client",
|
|
RedirectURI: "https://old.example.com/callback",
|
|
},
|
|
},
|
|
newFileContent: map[string]*funnelClient{
|
|
"new-client": {
|
|
ID: "new-client",
|
|
Secret: "new-secret",
|
|
Name: "New Client",
|
|
RedirectURI: "https://new.example.com/callback",
|
|
},
|
|
},
|
|
expectNewFileExists: true,
|
|
expectOldRenamed: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rootPath := t.TempDir()
|
|
|
|
// Setup old file if needed
|
|
if tt.setupOldFile {
|
|
oldData, err := json.Marshal(tt.oldFileContent)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal old file content: %v", err)
|
|
}
|
|
oldPath := rootPath + "/" + funnelClientsFile
|
|
if err := os.WriteFile(oldPath, oldData, 0600); err != nil {
|
|
t.Fatalf("failed to create old file: %v", err)
|
|
}
|
|
}
|
|
|
|
// Setup new file if needed
|
|
if tt.setupNewFile {
|
|
newData, err := json.Marshal(tt.newFileContent)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal new file content: %v", err)
|
|
}
|
|
newPath := rootPath + "/" + oauthClientsFile
|
|
if err := os.WriteFile(newPath, newData, 0600); err != nil {
|
|
t.Fatalf("failed to create new file: %v", err)
|
|
}
|
|
}
|
|
|
|
// Call migrateOAuthClients
|
|
resultPath, err := migrateOAuthClients(rootPath)
|
|
|
|
if tt.expectError && err == nil {
|
|
t.Fatalf("expected error but got none")
|
|
}
|
|
if !tt.expectError && err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if tt.expectError {
|
|
return
|
|
}
|
|
|
|
// Verify result path points to oauth-clients.json
|
|
expectedPath := filepath.Join(rootPath, oauthClientsFile)
|
|
if resultPath != expectedPath {
|
|
t.Errorf("expected result path %s, got %s", expectedPath, resultPath)
|
|
}
|
|
|
|
// Verify new file exists if expected
|
|
if tt.expectNewFileExists {
|
|
if _, err := os.Stat(resultPath); err != nil {
|
|
t.Errorf("expected new file to exist at %s: %v", resultPath, err)
|
|
}
|
|
|
|
// Verify content
|
|
data, err := os.ReadFile(resultPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read new file: %v", err)
|
|
}
|
|
|
|
var clients map[string]*funnelClient
|
|
if err := json.Unmarshal(data, &clients); err != nil {
|
|
t.Fatalf("failed to unmarshal new file: %v", err)
|
|
}
|
|
|
|
// Determine expected content
|
|
var expectedContent map[string]*funnelClient
|
|
if tt.setupNewFile {
|
|
expectedContent = tt.newFileContent
|
|
} else if tt.setupOldFile {
|
|
expectedContent = tt.oldFileContent
|
|
} else {
|
|
expectedContent = make(map[string]*funnelClient)
|
|
}
|
|
|
|
if len(clients) != len(expectedContent) {
|
|
t.Errorf("expected %d clients, got %d", len(expectedContent), len(clients))
|
|
}
|
|
|
|
for id, expectedClient := range expectedContent {
|
|
actualClient, ok := clients[id]
|
|
if !ok {
|
|
t.Errorf("expected client %s not found", id)
|
|
continue
|
|
}
|
|
if actualClient.ID != expectedClient.ID ||
|
|
actualClient.Secret != expectedClient.Secret ||
|
|
actualClient.Name != expectedClient.Name ||
|
|
actualClient.RedirectURI != expectedClient.RedirectURI {
|
|
t.Errorf("client %s mismatch: got %+v, want %+v", id, actualClient, expectedClient)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify old file renamed if expected
|
|
if tt.expectOldRenamed {
|
|
deprecatedPath := rootPath + "/" + deprecatedFunnelClientsFile
|
|
if _, err := os.Stat(deprecatedPath); err != nil {
|
|
t.Errorf("expected old file to be renamed to %s: %v", deprecatedPath, err)
|
|
}
|
|
|
|
// Verify original old file is gone
|
|
oldPath := rootPath + "/" + funnelClientsFile
|
|
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
|
|
t.Errorf("expected old file %s to be removed", oldPath)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetConfigFilePath verifies backward compatibility for config file location.
|
|
// The function must check current directory first (legacy deployments) before
|
|
// falling back to rootPath (new installations) to prevent breaking existing
|
|
// tsidp deployments that have config files in unexpected locations.
|
|
func TestGetConfigFilePath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fileName string
|
|
createInCwd bool
|
|
createInRoot bool
|
|
expectInCwd bool
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "file-in-cwd-use-cwd",
|
|
fileName: "test-config.json",
|
|
createInCwd: true,
|
|
expectInCwd: true,
|
|
},
|
|
{
|
|
name: "file-missing-use-root",
|
|
fileName: "test-config.json",
|
|
createInCwd: false,
|
|
expectInCwd: false,
|
|
},
|
|
{
|
|
name: "file-in-both-prefer-cwd",
|
|
fileName: "test-config.json",
|
|
createInCwd: true,
|
|
createInRoot: true,
|
|
expectInCwd: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create temporary directories
|
|
rootPath := t.TempDir()
|
|
originalWd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("failed to get working directory: %v", err)
|
|
}
|
|
|
|
// Create a temporary working directory
|
|
tmpWd := t.TempDir()
|
|
if err := os.Chdir(tmpWd); err != nil {
|
|
t.Fatalf("failed to change to temp directory: %v", err)
|
|
}
|
|
defer func() {
|
|
os.Chdir(originalWd)
|
|
}()
|
|
|
|
// Setup files as needed
|
|
if tt.createInCwd {
|
|
if err := os.WriteFile(tt.fileName, []byte("{}"), 0600); err != nil {
|
|
t.Fatalf("failed to create file in cwd: %v", err)
|
|
}
|
|
}
|
|
if tt.createInRoot {
|
|
rootFilePath := filepath.Join(rootPath, tt.fileName)
|
|
if err := os.WriteFile(rootFilePath, []byte("{}"), 0600); err != nil {
|
|
t.Fatalf("failed to create file in root: %v", err)
|
|
}
|
|
}
|
|
|
|
// Call getConfigFilePath
|
|
resultPath, err := getConfigFilePath(rootPath, tt.fileName)
|
|
|
|
if tt.expectError && err == nil {
|
|
t.Fatalf("expected error but got none")
|
|
}
|
|
if !tt.expectError && err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if tt.expectError {
|
|
return
|
|
}
|
|
|
|
// Verify result
|
|
if tt.expectInCwd {
|
|
if resultPath != tt.fileName {
|
|
t.Errorf("expected path %s, got %s", tt.fileName, resultPath)
|
|
}
|
|
} else {
|
|
expectedPath := filepath.Join(rootPath, tt.fileName)
|
|
if resultPath != expectedPath {
|
|
t.Errorf("expected path %s, got %s", expectedPath, resultPath)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAuthorizeStrictMode verifies OAuth authorization endpoint security and validation logic.
|
|
// Tests both the security boundary (funnel rejection) and the business logic (strict mode validation).
|
|
func TestAuthorizeStrictMode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
strictMode bool
|
|
clientID string
|
|
redirectURI string
|
|
state string
|
|
nonce string
|
|
setupClient bool
|
|
clientRedirect string
|
|
useFunnel bool // whether to simulate funnel request
|
|
mockWhoIsError bool // whether to make WhoIs return an error
|
|
expectError bool
|
|
expectCode int
|
|
expectRedirect bool
|
|
}{
|
|
// Security boundary test: funnel rejection
|
|
{
|
|
name: "funnel-rejected",
|
|
strictMode: true,
|
|
clientID: "test-client",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
state: "random-state",
|
|
nonce: "random-nonce",
|
|
setupClient: true,
|
|
clientRedirect: "https://rp.example.com/callback",
|
|
useFunnel: true,
|
|
expectError: true,
|
|
expectCode: http.StatusUnauthorized,
|
|
},
|
|
|
|
// Strict mode parameter validation tests (non-funnel)
|
|
{
|
|
name: "strict-missing-client_id",
|
|
strictMode: true,
|
|
clientID: "",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
useFunnel: false,
|
|
expectError: true,
|
|
expectCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "strict-missing-redirect_uri",
|
|
strictMode: true,
|
|
clientID: "test-client",
|
|
redirectURI: "",
|
|
useFunnel: false,
|
|
expectError: true,
|
|
expectCode: http.StatusBadRequest,
|
|
},
|
|
|
|
// Strict mode client validation tests (non-funnel)
|
|
{
|
|
name: "strict-invalid-client_id",
|
|
strictMode: true,
|
|
clientID: "invalid-client",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
setupClient: false,
|
|
useFunnel: false,
|
|
expectError: true,
|
|
expectCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "strict-redirect_uri-mismatch",
|
|
strictMode: true,
|
|
clientID: "test-client",
|
|
redirectURI: "https://wrong.example.com/callback",
|
|
setupClient: true,
|
|
clientRedirect: "https://rp.example.com/callback",
|
|
useFunnel: false,
|
|
expectError: true,
|
|
expectCode: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
srv := setupTestServer(t, tt.strictMode)
|
|
|
|
// For non-funnel tests, we'll test the parameter validation logic
|
|
// without needing to mock WhoIs, since the validation happens before WhoIs calls
|
|
|
|
// Setup client if needed
|
|
if tt.setupClient {
|
|
srv.funnelClients["test-client"] = &funnelClient{
|
|
ID: "test-client",
|
|
Secret: "test-secret",
|
|
Name: "Test Client",
|
|
RedirectURI: tt.clientRedirect,
|
|
}
|
|
} else if !tt.strictMode {
|
|
// For non-strict mode tests that don't need a specific client setup
|
|
// but might reference one, clear the default client
|
|
delete(srv.funnelClients, "test-client")
|
|
}
|
|
|
|
// Create request
|
|
reqURL := "/authorize"
|
|
if !tt.strictMode {
|
|
// In non-strict mode, use the node-specific endpoint
|
|
reqURL = "/authorize/123"
|
|
}
|
|
|
|
query := url.Values{}
|
|
if tt.clientID != "" {
|
|
query.Set("client_id", tt.clientID)
|
|
}
|
|
if tt.redirectURI != "" {
|
|
query.Set("redirect_uri", tt.redirectURI)
|
|
}
|
|
if tt.state != "" {
|
|
query.Set("state", tt.state)
|
|
}
|
|
if tt.nonce != "" {
|
|
query.Set("nonce", tt.nonce)
|
|
}
|
|
|
|
reqURL += "?" + query.Encode()
|
|
req := httptest.NewRequest("GET", reqURL, nil)
|
|
req.RemoteAddr = "127.0.0.1:12345"
|
|
|
|
// Set funnel header only when explicitly testing funnel behavior
|
|
if tt.useFunnel {
|
|
req.Header.Set("Tailscale-Funnel-Request", "true")
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.authorize(rr, req)
|
|
|
|
if tt.expectError {
|
|
if rr.Code != tt.expectCode {
|
|
t.Errorf("expected status code %d, got %d: %s", tt.expectCode, rr.Code, rr.Body.String())
|
|
}
|
|
} else if tt.expectRedirect {
|
|
if rr.Code != http.StatusFound {
|
|
t.Errorf("expected redirect (302), got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
location := rr.Header().Get("Location")
|
|
if location == "" {
|
|
t.Error("expected Location header in redirect response")
|
|
} else {
|
|
// Parse the redirect URL to verify it contains a code
|
|
redirectURL, err := url.Parse(location)
|
|
if err != nil {
|
|
t.Errorf("failed to parse redirect URL: %v", err)
|
|
} else {
|
|
code := redirectURL.Query().Get("code")
|
|
if code == "" {
|
|
t.Error("expected 'code' parameter in redirect URL")
|
|
}
|
|
|
|
// Verify state is preserved if provided
|
|
if tt.state != "" {
|
|
returnedState := redirectURL.Query().Get("state")
|
|
if returnedState != tt.state {
|
|
t.Errorf("expected state '%s', got '%s'", tt.state, returnedState)
|
|
}
|
|
}
|
|
|
|
// Verify the auth request was stored
|
|
srv.mu.Lock()
|
|
ar, ok := srv.code[code]
|
|
srv.mu.Unlock()
|
|
|
|
if !ok {
|
|
t.Error("expected authorization request to be stored")
|
|
} else {
|
|
if ar.clientID != tt.clientID {
|
|
t.Errorf("expected clientID '%s', got '%s'", tt.clientID, ar.clientID)
|
|
}
|
|
if ar.redirectURI != tt.redirectURI {
|
|
t.Errorf("expected redirectURI '%s', got '%s'", tt.redirectURI, ar.redirectURI)
|
|
}
|
|
if ar.nonce != tt.nonce {
|
|
t.Errorf("expected nonce '%s', got '%s'", tt.nonce, ar.nonce)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
t.Errorf("unexpected test case: not expecting error or redirect")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestServeTokenWithClientValidation verifies OAuth token endpoint security in both strict and non-strict modes.
|
|
// In strict mode, the token endpoint must:
|
|
// - Require and validate client credentials (client_id + client_secret)
|
|
// - Only accept tokens from registered funnel clients
|
|
// - Validate that redirect_uri matches the registered client
|
|
// - Support both form-based and HTTP Basic authentication for client credentials
|
|
func TestServeTokenWithClientValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
strictMode bool
|
|
method string
|
|
grantType string
|
|
code string
|
|
clientID string
|
|
clientSecret string
|
|
redirectURI string
|
|
useBasicAuth bool
|
|
setupAuthRequest bool
|
|
authRequestClient string
|
|
authRequestRedirect string
|
|
expectError bool
|
|
expectCode int
|
|
expectIDToken bool
|
|
}{
|
|
{
|
|
name: "strict-token-exchange-form-creds",
|
|
strictMode: true,
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
code: "valid-code",
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
setupAuthRequest: true,
|
|
authRequestClient: "test-client",
|
|
authRequestRedirect: "https://rp.example.com/callback",
|
|
expectIDToken: true,
|
|
},
|
|
{
|
|
name: "strict-token-exchange-basic-auth",
|
|
strictMode: true,
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
code: "valid-code",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
useBasicAuth: true,
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
setupAuthRequest: true,
|
|
authRequestClient: "test-client",
|
|
authRequestRedirect: "https://rp.example.com/callback",
|
|
expectIDToken: true,
|
|
},
|
|
{
|
|
name: "strict-missing-client-creds",
|
|
strictMode: true,
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
code: "valid-code",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
setupAuthRequest: true,
|
|
authRequestClient: "test-client",
|
|
authRequestRedirect: "https://rp.example.com/callback",
|
|
expectError: true,
|
|
expectCode: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "strict-client_id-mismatch",
|
|
strictMode: true,
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
code: "valid-code",
|
|
clientID: "wrong-client",
|
|
clientSecret: "test-secret",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
setupAuthRequest: true,
|
|
authRequestClient: "test-client",
|
|
expectError: true,
|
|
expectCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "strict-invalid-client-secret",
|
|
strictMode: true,
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
code: "valid-code",
|
|
clientID: "test-client",
|
|
clientSecret: "wrong-secret",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
setupAuthRequest: true,
|
|
authRequestClient: "test-client",
|
|
authRequestRedirect: "https://rp.example.com/callback",
|
|
expectError: true,
|
|
expectCode: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "strict-redirect_uri-mismatch",
|
|
strictMode: true,
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
code: "valid-code",
|
|
clientID: "test-client",
|
|
clientSecret: "test-secret",
|
|
redirectURI: "https://wrong.example.com/callback",
|
|
setupAuthRequest: true,
|
|
authRequestClient: "test-client",
|
|
authRequestRedirect: "https://rp.example.com/callback",
|
|
expectError: true,
|
|
expectCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "non-strict-no-client-validation",
|
|
strictMode: false,
|
|
method: "POST",
|
|
grantType: "authorization_code",
|
|
code: "valid-code",
|
|
redirectURI: "https://rp.example.com/callback",
|
|
setupAuthRequest: true,
|
|
authRequestRedirect: "https://rp.example.com/callback",
|
|
expectIDToken: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
srv := setupTestServer(t, tt.strictMode)
|
|
|
|
// Setup authorization request if needed
|
|
if tt.setupAuthRequest {
|
|
now := time.Now()
|
|
profile := &tailcfg.UserProfile{
|
|
LoginName: "alice@example.com",
|
|
DisplayName: "Alice Example",
|
|
ProfilePicURL: "https://example.com/alice.jpg",
|
|
}
|
|
node := &tailcfg.Node{
|
|
ID: 123,
|
|
Name: "test-node.test.ts.net.",
|
|
User: 456,
|
|
Key: key.NodePublic{},
|
|
Cap: 1,
|
|
DiscoKey: key.DiscoPublic{},
|
|
}
|
|
remoteUser := &apitype.WhoIsResponse{
|
|
Node: node,
|
|
UserProfile: profile,
|
|
CapMap: tailcfg.PeerCapMap{},
|
|
}
|
|
|
|
var funnelClientPtr *funnelClient
|
|
if tt.strictMode && tt.authRequestClient != "" {
|
|
funnelClientPtr = &funnelClient{
|
|
ID: tt.authRequestClient,
|
|
Secret: "test-secret",
|
|
Name: "Test Client",
|
|
RedirectURI: tt.authRequestRedirect,
|
|
}
|
|
srv.funnelClients[tt.authRequestClient] = funnelClientPtr
|
|
}
|
|
|
|
srv.code["valid-code"] = &authRequest{
|
|
clientID: tt.authRequestClient,
|
|
nonce: "nonce123",
|
|
redirectURI: tt.authRequestRedirect,
|
|
validTill: now.Add(5 * time.Minute),
|
|
remoteUser: remoteUser,
|
|
localRP: !tt.strictMode,
|
|
funnelRP: funnelClientPtr,
|
|
}
|
|
}
|
|
|
|
// Create form data
|
|
form := url.Values{}
|
|
form.Set("grant_type", tt.grantType)
|
|
form.Set("code", tt.code)
|
|
form.Set("redirect_uri", tt.redirectURI)
|
|
|
|
if !tt.useBasicAuth {
|
|
if tt.clientID != "" {
|
|
form.Set("client_id", tt.clientID)
|
|
}
|
|
if tt.clientSecret != "" {
|
|
form.Set("client_secret", tt.clientSecret)
|
|
}
|
|
}
|
|
|
|
req := httptest.NewRequest(tt.method, "/token", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.RemoteAddr = "127.0.0.1:12345"
|
|
|
|
if tt.useBasicAuth && tt.clientID != "" && tt.clientSecret != "" {
|
|
req.SetBasicAuth(tt.clientID, tt.clientSecret)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.serveToken(rr, req)
|
|
|
|
if tt.expectError {
|
|
if rr.Code != tt.expectCode {
|
|
t.Errorf("expected status code %d, got %d: %s", tt.expectCode, rr.Code, rr.Body.String())
|
|
}
|
|
} else if tt.expectIDToken {
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("expected 200 OK, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
IDToken string `json:"id_token"`
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if resp.IDToken == "" {
|
|
t.Error("expected id_token in response")
|
|
}
|
|
if resp.AccessToken == "" {
|
|
t.Error("expected access_token in response")
|
|
}
|
|
if resp.TokenType != "Bearer" {
|
|
t.Errorf("expected token_type 'Bearer', got '%s'", resp.TokenType)
|
|
}
|
|
if resp.ExpiresIn != 300 {
|
|
t.Errorf("expected expires_in 300, got %d", resp.ExpiresIn)
|
|
}
|
|
|
|
// Verify access token was stored
|
|
srv.mu.Lock()
|
|
_, ok := srv.accessToken[resp.AccessToken]
|
|
srv.mu.Unlock()
|
|
|
|
if !ok {
|
|
t.Error("expected access token to be stored")
|
|
}
|
|
|
|
// Verify authorization code was consumed
|
|
srv.mu.Lock()
|
|
_, ok = srv.code[tt.code]
|
|
srv.mu.Unlock()
|
|
|
|
if ok {
|
|
t.Error("expected authorization code to be consumed")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestServeUserInfoWithClientValidation verifies UserInfo endpoint security in both strict and non-strict modes.
|
|
// In strict mode, the UserInfo endpoint must:
|
|
// - Validate that access tokens are associated with registered clients
|
|
// - Reject tokens for clients that have been deleted/unregistered
|
|
// - Enforce token expiration properly
|
|
// - Return appropriate user claims based on client capabilities
|
|
func TestServeUserInfoWithClientValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
strictMode bool
|
|
setupToken bool
|
|
setupClient bool
|
|
clientID string
|
|
token string
|
|
tokenValidTill time.Time
|
|
expectError bool
|
|
expectCode int
|
|
expectUserInfo bool
|
|
}{
|
|
{
|
|
name: "strict-valid-token-existing-client",
|
|
strictMode: true,
|
|
setupToken: true,
|
|
setupClient: true,
|
|
clientID: "test-client",
|
|
token: "valid-token",
|
|
tokenValidTill: time.Now().Add(5 * time.Minute),
|
|
expectUserInfo: true,
|
|
},
|
|
{
|
|
// valid token but client no longer exists
|
|
name: "strict-token-client-deleted",
|
|
strictMode: true,
|
|
setupToken: true,
|
|
setupClient: false,
|
|
clientID: "deleted-client",
|
|
token: "valid-token",
|
|
tokenValidTill: time.Now().Add(5 * time.Minute),
|
|
expectError: true,
|
|
expectCode: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "strict-expired-token",
|
|
strictMode: true,
|
|
setupToken: true,
|
|
setupClient: true,
|
|
clientID: "test-client",
|
|
token: "expired-token",
|
|
tokenValidTill: time.Now().Add(-5 * time.Minute),
|
|
expectError: true,
|
|
expectCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "strict-invalid-token",
|
|
strictMode: true,
|
|
setupToken: false,
|
|
token: "invalid-token",
|
|
expectError: true,
|
|
expectCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "strict-token-no-client-assoc",
|
|
strictMode: true,
|
|
setupToken: true,
|
|
setupClient: false,
|
|
clientID: "",
|
|
token: "valid-token",
|
|
tokenValidTill: time.Now().Add(5 * time.Minute),
|
|
expectError: true,
|
|
expectCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "non-strict-no-client-validation",
|
|
strictMode: false,
|
|
setupToken: true,
|
|
setupClient: false,
|
|
clientID: "",
|
|
token: "valid-token",
|
|
tokenValidTill: time.Now().Add(5 * time.Minute),
|
|
expectUserInfo: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
srv := setupTestServer(t, tt.strictMode)
|
|
|
|
// Setup client if needed
|
|
if tt.setupClient {
|
|
srv.funnelClients[tt.clientID] = &funnelClient{
|
|
ID: tt.clientID,
|
|
Secret: "test-secret",
|
|
Name: "Test Client",
|
|
RedirectURI: "https://rp.example.com/callback",
|
|
}
|
|
}
|
|
|
|
// Setup token if needed
|
|
if tt.setupToken {
|
|
profile := &tailcfg.UserProfile{
|
|
LoginName: "alice@example.com",
|
|
DisplayName: "Alice Example",
|
|
ProfilePicURL: "https://example.com/alice.jpg",
|
|
}
|
|
node := &tailcfg.Node{
|
|
ID: 123,
|
|
Name: "test-node.test.ts.net.",
|
|
User: 456,
|
|
Key: key.NodePublic{},
|
|
Cap: 1,
|
|
DiscoKey: key.DiscoPublic{},
|
|
}
|
|
remoteUser := &apitype.WhoIsResponse{
|
|
Node: node,
|
|
UserProfile: profile,
|
|
CapMap: tailcfg.PeerCapMap{},
|
|
}
|
|
|
|
srv.accessToken[tt.token] = &authRequest{
|
|
clientID: tt.clientID,
|
|
validTill: tt.tokenValidTill,
|
|
remoteUser: remoteUser,
|
|
}
|
|
}
|
|
|
|
// Create request
|
|
req := httptest.NewRequest("GET", "/userinfo", nil)
|
|
req.Header.Set("Authorization", "Bearer "+tt.token)
|
|
req.RemoteAddr = "127.0.0.1:12345"
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.serveUserInfo(rr, req)
|
|
|
|
if tt.expectError {
|
|
if rr.Code != tt.expectCode {
|
|
t.Errorf("expected status code %d, got %d: %s", tt.expectCode, rr.Code, rr.Body.String())
|
|
}
|
|
} else if tt.expectUserInfo {
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("expected 200 OK, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to parse JSON response: %v", err)
|
|
}
|
|
|
|
// Check required fields
|
|
expectedFields := []string{"sub", "name", "email", "picture", "username"}
|
|
for _, field := range expectedFields {
|
|
if _, ok := resp[field]; !ok {
|
|
t.Errorf("expected field '%s' in user info response", field)
|
|
}
|
|
}
|
|
|
|
// Verify specific values
|
|
if resp["name"] != "Alice Example" {
|
|
t.Errorf("expected name 'Alice Example', got '%v'", resp["name"])
|
|
}
|
|
if resp["email"] != "alice@example.com" {
|
|
t.Errorf("expected email 'alice@example.com', got '%v'", resp["email"])
|
|
}
|
|
if resp["username"] != "alice" {
|
|
t.Errorf("expected username 'alice', got '%v'", resp["username"])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|