2023-11-13 23:18:58 +00:00
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// The tsidp command is an OpenID Connect Identity Provider server.
//
// See https://github.com/tailscale/tailscale/issues/10263 for background.
package main
import (
2024-08-08 14:46:45 +00:00
"bytes"
2023-11-13 23:18:58 +00:00
"context"
crand "crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/json"
"encoding/pem"
2024-08-08 14:46:45 +00:00
"errors"
2023-11-13 23:18:58 +00:00
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"net/url"
"os"
2024-08-08 14:46:45 +00:00
"os/signal"
2023-11-13 23:18:58 +00:00
"strconv"
"strings"
"sync"
"time"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/envknob"
2024-08-08 14:46:45 +00:00
"tailscale.com/ipn"
2023-11-13 23:18:58 +00:00
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/types/key"
"tailscale.com/types/lazy"
"tailscale.com/types/views"
"tailscale.com/util/mak"
"tailscale.com/util/must"
"tailscale.com/util/rands"
2024-08-08 14:46:45 +00:00
"tailscale.com/version"
2023-11-13 23:18:58 +00:00
)
2024-08-08 14:46:45 +00:00
// ctxConn is a key to look up a net.Conn stored in an HTTP request's context.
type ctxConn struct { }
// funnelClientsFile is the file where client IDs and secrets for OIDC clients
// accessing the IDP over Funnel are persisted.
const funnelClientsFile = "oidc-funnel-clients.json"
2023-11-13 23:18:58 +00:00
var (
flagVerbose = flag . Bool ( "verbose" , false , "be verbose" )
flagPort = flag . Int ( "port" , 443 , "port to listen on" )
flagLocalPort = flag . Int ( "local-port" , - 1 , "allow requests from localhost" )
flagUseLocalTailscaled = flag . Bool ( "use-local-tailscaled" , false , "use local tailscaled instead of tsnet" )
2024-08-08 14:46:45 +00:00
flagFunnel = flag . Bool ( "funnel" , false , "use Tailscale Funnel to make tsidp available on the public internet" )
2024-09-26 13:18:15 +00:00
flagDir = flag . String ( "dir" , "" , "tsnet state directory; a default one will be created if not provided" )
2023-11-13 23:18:58 +00:00
)
func main ( ) {
flag . Parse ( )
ctx := context . Background ( )
if ! envknob . UseWIPCode ( ) {
log . Fatal ( "cmd/tsidp is a work in progress and has not been security reviewed;\nits use requires TAILSCALE_USE_WIP_CODE=1 be set in the environment for now." )
}
var (
2024-08-08 14:46:45 +00:00
lc * tailscale . LocalClient
st * ipnstate . Status
err error
watcherChan chan error
cleanup func ( )
2023-11-13 23:18:58 +00:00
lns [ ] net . Listener
)
if * flagUseLocalTailscaled {
lc = & tailscale . LocalClient { }
st , err = lc . StatusWithoutPeers ( ctx )
if err != nil {
log . Fatalf ( "getting status: %v" , err )
}
portStr := fmt . Sprint ( * flagPort )
anySuccess := false
for _ , ip := range st . TailscaleIPs {
ln , err := net . Listen ( "tcp" , net . JoinHostPort ( ip . String ( ) , portStr ) )
if err != nil {
log . Printf ( "failed to listen on %v: %v" , ip , err )
continue
}
anySuccess = true
ln = tls . NewListener ( ln , & tls . Config {
GetCertificate : lc . GetCertificate ,
} )
lns = append ( lns , ln )
}
if ! anySuccess {
log . Fatalf ( "failed to listen on any of %v" , st . TailscaleIPs )
}
2024-08-08 14:46:45 +00:00
// tailscaled needs to be setting an HTTP header for funneled requests
// that older versions don't provide.
// TODO(naman): is this the correct check?
if * flagFunnel && ! version . AtLeast ( st . Version , "1.71.0" ) {
log . Fatalf ( "Local tailscaled not new enough to support -funnel. Update Tailscale or use tsnet mode." )
}
cleanup , watcherChan , err = serveOnLocalTailscaled ( ctx , lc , st , uint16 ( * flagPort ) , * flagFunnel )
if err != nil {
log . Fatalf ( "could not serve on local tailscaled: %v" , err )
}
defer cleanup ( )
2023-11-13 23:18:58 +00:00
} else {
ts := & tsnet . Server {
Hostname : "idp" ,
2024-09-26 13:18:15 +00:00
Dir : * flagDir ,
2023-11-13 23:18:58 +00:00
}
2024-05-10 20:12:11 +00:00
if * flagVerbose {
ts . Logf = log . Printf
2023-11-13 23:18:58 +00:00
}
st , err = ts . Up ( ctx )
if err != nil {
log . Fatal ( err )
}
lc , err = ts . LocalClient ( )
if err != nil {
log . Fatalf ( "getting local client: %v" , err )
}
2024-08-08 14:46:45 +00:00
var ln net . Listener
if * flagFunnel {
if err := ipn . CheckFunnelAccess ( uint16 ( * flagPort ) , st . Self ) ; err != nil {
log . Fatalf ( "%v" , err )
}
ln , err = ts . ListenFunnel ( "tcp" , fmt . Sprintf ( ":%d" , * flagPort ) )
} else {
ln , err = ts . ListenTLS ( "tcp" , fmt . Sprintf ( ":%d" , * flagPort ) )
}
2023-11-13 23:18:58 +00:00
if err != nil {
log . Fatal ( err )
}
lns = append ( lns , ln )
}
srv := & idpServer {
2024-08-08 14:46:45 +00:00
lc : lc ,
funnel : * flagFunnel ,
localTSMode : * flagUseLocalTailscaled ,
2023-11-13 23:18:58 +00:00
}
if * flagPort != 443 {
srv . serverURL = fmt . Sprintf ( "https://%s:%d" , strings . TrimSuffix ( st . Self . DNSName , "." ) , * flagPort )
} else {
srv . serverURL = fmt . Sprintf ( "https://%s" , strings . TrimSuffix ( st . Self . DNSName , "." ) )
}
2024-08-08 14:46:45 +00:00
if * flagFunnel {
f , err := os . Open ( funnelClientsFile )
if err == nil {
srv . funnelClients = make ( map [ string ] * funnelClient )
if err := json . NewDecoder ( f ) . Decode ( & srv . funnelClients ) ; err != nil {
log . Fatalf ( "could not parse %s: %v" , funnelClientsFile , err )
}
} else if ! errors . Is ( err , os . ErrNotExist ) {
log . Fatalf ( "could not open %s: %v" , funnelClientsFile , err )
}
}
2023-11-13 23:18:58 +00:00
log . Printf ( "Running tsidp at %s ..." , srv . serverURL )
if * flagLocalPort != - 1 {
log . Printf ( "Also running tsidp at %s ..." , srv . loopbackURL )
srv . loopbackURL = fmt . Sprintf ( "http://localhost:%d" , * flagLocalPort )
ln , err := net . Listen ( "tcp" , fmt . Sprintf ( "localhost:%d" , * flagLocalPort ) )
if err != nil {
log . Fatal ( err )
}
lns = append ( lns , ln )
}
for _ , ln := range lns {
2024-08-08 14:46:45 +00:00
server := http . Server {
Handler : srv ,
ConnContext : func ( ctx context . Context , c net . Conn ) context . Context {
return context . WithValue ( ctx , ctxConn { } , c )
} ,
}
go server . Serve ( ln )
}
// need to catch os.Interrupt, otherwise deferred cleanup code doesn't run
exitChan := make ( chan os . Signal , 1 )
signal . Notify ( exitChan , os . Interrupt )
select {
case <- exitChan :
log . Printf ( "interrupt, exiting" )
return
case <- watcherChan :
if errors . Is ( err , io . EOF ) || errors . Is ( err , context . Canceled ) {
log . Printf ( "watcher closed, exiting" )
return
}
log . Fatalf ( "watcher error: %v" , err )
return
}
}
// serveOnLocalTailscaled starts a serve session using an already-running
// tailscaled instead of starting a fresh tsnet server, making something
// listening on clientDNSName:dstPort accessible over serve/funnel.
func serveOnLocalTailscaled ( ctx context . Context , lc * tailscale . LocalClient , st * ipnstate . Status , dstPort uint16 , shouldFunnel bool ) ( cleanup func ( ) , watcherChan chan error , err error ) {
// In order to support funneling out in local tailscaled mode, we need
// to add a serve config to forward the listeners we bound above and
// allow those forwarders to be funneled out.
sc , err := lc . GetServeConfig ( ctx )
if err != nil {
return nil , nil , fmt . Errorf ( "could not get serve config: %v" , err )
}
if sc == nil {
sc = new ( ipn . ServeConfig )
}
// We watch the IPN bus just to get a session ID. The session expires
// when we stop watching the bus, and that auto-deletes the foreground
// serve/funnel configs we are creating below.
watcher , err := lc . WatchIPNBus ( ctx , ipn . NotifyInitialState | ipn . NotifyNoPrivateKeys )
if err != nil {
return nil , nil , fmt . Errorf ( "could not set up ipn bus watcher: %v" , err )
}
defer func ( ) {
if err != nil {
watcher . Close ( )
}
} ( )
n , err := watcher . Next ( )
if err != nil {
return nil , nil , fmt . Errorf ( "could not get initial state from ipn bus watcher: %v" , err )
}
if n . SessionID == "" {
err = fmt . Errorf ( "missing sessionID in ipn.Notify" )
return nil , nil , err
}
watcherChan = make ( chan error )
go func ( ) {
for {
_ , err = watcher . Next ( )
if err != nil {
watcherChan <- err
return
}
}
} ( )
// Create a foreground serve config that gets cleaned up when tsidp
// exits and the session ID associated with this config is invalidated.
foregroundSc := new ( ipn . ServeConfig )
mak . Set ( & sc . Foreground , n . SessionID , foregroundSc )
serverURL := strings . TrimSuffix ( st . Self . DNSName , "." )
fmt . Printf ( "setting funnel for %s:%v\n" , serverURL , dstPort )
foregroundSc . SetFunnel ( serverURL , dstPort , shouldFunnel )
foregroundSc . SetWebHandler ( & ipn . HTTPHandler {
Proxy : fmt . Sprintf ( "https://%s" , net . JoinHostPort ( serverURL , strconv . Itoa ( int ( dstPort ) ) ) ) ,
} , serverURL , uint16 ( * flagPort ) , "/" , true )
err = lc . SetServeConfig ( ctx , sc )
if err != nil {
return nil , watcherChan , fmt . Errorf ( "could not set serve config: %v" , err )
2023-11-13 23:18:58 +00:00
}
2024-08-08 14:46:45 +00:00
return func ( ) { watcher . Close ( ) } , watcherChan , nil
2023-11-13 23:18:58 +00:00
}
type idpServer struct {
lc * tailscale . LocalClient
loopbackURL string
serverURL string // "https://foo.bar.ts.net"
2024-08-08 14:46:45 +00:00
funnel bool
localTSMode bool
2023-11-13 23:18:58 +00:00
lazyMux lazy . SyncValue [ * http . ServeMux ]
lazySigningKey lazy . SyncValue [ * signingKey ]
lazySigner lazy . SyncValue [ jose . Signer ]
2024-08-08 14:46:45 +00:00
mu sync . Mutex // guards the fields below
code map [ string ] * authRequest // keyed by random hex
accessToken map [ string ] * authRequest // keyed by random hex
funnelClients map [ string ] * funnelClient // keyed by client ID
2023-11-13 23:18:58 +00:00
}
type authRequest struct {
// localRP is true if the request is from a relying party running on the
2024-08-08 14:46:45 +00:00
// same machine as the idp server. It is mutually exclusive with rpNodeID
// and funnelRP.
2023-11-13 23:18:58 +00:00
localRP bool
// rpNodeID is the NodeID of the relying party (who requested the auth, such
// as Proxmox or Synology), not the user node who is being authenticated. It
2024-08-08 14:46:45 +00:00
// is mutually exclusive with localRP and funnelRP.
2023-11-13 23:18:58 +00:00
rpNodeID tailcfg . NodeID
2024-08-08 14:46:45 +00:00
// funnelRP is non-nil if the request is from a relying party outside the
// tailnet, via Tailscale Funnel. It is mutually exclusive with rpNodeID
// and localRP.
funnelRP * funnelClient
2023-11-13 23:18:58 +00:00
// clientID is the "client_id" sent in the authorized request.
clientID string
// nonce presented in the request.
nonce string
// redirectURI is the redirect_uri presented in the request.
redirectURI string
// remoteUser is the user who is being authenticated.
remoteUser * apitype . WhoIsResponse
// validTill is the time until which the token is valid.
// As of 2023-11-14, it is 5 minutes.
// TODO: add routine to delete expired tokens.
validTill time . Time
}
2024-08-08 14:46:45 +00:00
// allowRelyingParty validates that a relying party identified either by a
// known remoteAddr or a valid client ID/secret pair is allowed to proceed
// with the authorization flow associated with this authRequest.
func ( ar * authRequest ) allowRelyingParty ( r * http . Request , lc * tailscale . LocalClient ) error {
2023-11-13 23:18:58 +00:00
if ar . localRP {
2024-08-08 14:46:45 +00:00
ra , err := netip . ParseAddrPort ( r . RemoteAddr )
2023-11-13 23:18:58 +00:00
if err != nil {
return err
}
if ! ra . Addr ( ) . IsLoopback ( ) {
return fmt . Errorf ( "tsidp: request from non-loopback address" )
}
return nil
}
2024-08-08 14:46:45 +00:00
if ar . funnelRP != nil {
clientID , clientSecret , ok := r . BasicAuth ( )
if ! ok {
clientID = r . FormValue ( "client_id" )
clientSecret = r . FormValue ( "client_secret" )
}
if ar . funnelRP . ID != clientID || ar . funnelRP . Secret != clientSecret {
return fmt . Errorf ( "tsidp: invalid client credentials" )
}
return nil
}
who , err := lc . WhoIs ( r . Context ( ) , r . RemoteAddr )
2023-11-13 23:18:58 +00:00
if err != nil {
return fmt . Errorf ( "tsidp: error getting WhoIs: %w" , err )
}
if ar . rpNodeID != who . Node . ID {
return fmt . Errorf ( "tsidp: token for different node" )
}
return nil
}
func ( s * idpServer ) authorize ( w http . ResponseWriter , r * http . Request ) {
2024-08-08 14:46:45 +00:00
// This URL is visited by the user who is being authenticated. If they are
// visiting the URL over Funnel, that means they are not part of the
// tailnet that they are trying to be authenticated for.
if isFunnelRequest ( r ) {
http . Error ( w , "tsidp: unauthorized" , http . StatusUnauthorized )
return
}
uq := r . URL . Query ( )
redirectURI := uq . Get ( "redirect_uri" )
if redirectURI == "" {
http . Error ( w , "tsidp: must specify redirect_uri" , http . StatusBadRequest )
return
}
var remoteAddr string
if s . localTSMode {
// in local tailscaled mode, the local tailscaled is forwarding us
// HTTP requests, so reading r.RemoteAddr will just get us our own
// address.
remoteAddr = r . Header . Get ( "X-Forwarded-For" )
} else {
remoteAddr = r . RemoteAddr
}
who , err := s . lc . WhoIs ( r . Context ( ) , remoteAddr )
2023-11-13 23:18:58 +00:00
if err != nil {
log . Printf ( "Error getting WhoIs: %v" , err )
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
code := rands . HexString ( 32 )
ar := & authRequest {
nonce : uq . Get ( "nonce" ) ,
remoteUser : who ,
2024-08-08 14:46:45 +00:00
redirectURI : redirectURI ,
2023-11-13 23:18:58 +00:00
clientID : uq . Get ( "client_id" ) ,
}
2024-08-08 14:46:45 +00:00
if r . URL . Path == "/authorize/funnel" {
s . mu . Lock ( )
c , ok := s . funnelClients [ ar . clientID ]
s . mu . Unlock ( )
if ! ok {
http . Error ( w , "tsidp: invalid client ID" , http . StatusBadRequest )
return
}
if ar . redirectURI != c . RedirectURI {
http . Error ( w , "tsidp: redirect_uri mismatch" , http . StatusBadRequest )
return
}
ar . funnelRP = c
} else if r . URL . Path == "/authorize/localhost" {
2023-11-13 23:18:58 +00:00
ar . localRP = true
} else {
var ok bool
ar . rpNodeID , ok = parseID [ tailcfg . NodeID ] ( strings . TrimPrefix ( r . URL . Path , "/authorize/" ) )
if ! ok {
http . Error ( w , "tsidp: invalid node ID suffix after /authorize/" , http . StatusBadRequest )
return
}
}
s . mu . Lock ( )
mak . Set ( & s . code , code , ar )
s . mu . Unlock ( )
q := make ( url . Values )
q . Set ( "code" , code )
2024-08-08 14:46:45 +00:00
if state := uq . Get ( "state" ) ; state != "" {
q . Set ( "state" , state )
}
u := redirectURI + "?" + q . Encode ( )
2023-11-13 23:18:58 +00:00
log . Printf ( "Redirecting to %q" , u )
http . Redirect ( w , r , u , http . StatusFound )
}
func ( s * idpServer ) newMux ( ) * http . ServeMux {
mux := http . NewServeMux ( )
mux . HandleFunc ( oidcJWKSPath , s . serveJWKS )
mux . HandleFunc ( oidcConfigPath , s . serveOpenIDConfig )
mux . HandleFunc ( "/authorize/" , s . authorize )
mux . HandleFunc ( "/userinfo" , s . serveUserInfo )
mux . HandleFunc ( "/token" , s . serveToken )
2024-08-08 14:46:45 +00:00
mux . HandleFunc ( "/clients/" , s . serveClients )
2023-11-13 23:18:58 +00:00
mux . HandleFunc ( "/" , func ( w http . ResponseWriter , r * http . Request ) {
if r . URL . Path == "/" {
io . WriteString ( w , "<html><body><h1>Tailscale OIDC IdP</h1>" )
return
}
http . Error ( w , "tsidp: not found" , http . StatusNotFound )
} )
return mux
}
func ( s * idpServer ) ServeHTTP ( w http . ResponseWriter , r * http . Request ) {
log . Printf ( "%v %v" , r . Method , r . URL )
s . lazyMux . Get ( s . newMux ) . ServeHTTP ( w , r )
}
func ( s * idpServer ) serveUserInfo ( w http . ResponseWriter , r * http . Request ) {
if r . Method != "GET" {
http . Error ( w , "tsidp: method not allowed" , http . StatusMethodNotAllowed )
return
}
tk , ok := strings . CutPrefix ( r . Header . Get ( "Authorization" ) , "Bearer " )
if ! ok {
http . Error ( w , "tsidp: invalid Authorization header" , http . StatusBadRequest )
return
}
s . mu . Lock ( )
ar , ok := s . accessToken [ tk ]
s . mu . Unlock ( )
if ! ok {
http . Error ( w , "tsidp: invalid token" , http . StatusBadRequest )
return
}
if ar . validTill . Before ( time . Now ( ) ) {
http . Error ( w , "tsidp: token expired" , http . StatusBadRequest )
s . mu . Lock ( )
delete ( s . accessToken , tk )
s . mu . Unlock ( )
}
ui := userInfo { }
if ar . remoteUser . Node . IsTagged ( ) {
http . Error ( w , "tsidp: tagged nodes not supported" , http . StatusBadRequest )
return
}
ui . Sub = ar . remoteUser . Node . User . String ( )
ui . Name = ar . remoteUser . UserProfile . DisplayName
ui . Email = ar . remoteUser . UserProfile . LoginName
ui . Picture = ar . remoteUser . UserProfile . ProfilePicURL
// TODO(maisem): not sure if this is the right thing to do
ui . UserName , _ , _ = strings . Cut ( ar . remoteUser . UserProfile . LoginName , "@" )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
if err := json . NewEncoder ( w ) . Encode ( ui ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
}
}
type userInfo struct {
Sub string ` json:"sub" `
Name string ` json:"name" `
Email string ` json:"email" `
Picture string ` json:"picture" `
UserName string ` json:"username" `
}
func ( s * idpServer ) serveToken ( w http . ResponseWriter , r * http . Request ) {
if r . Method != "POST" {
http . Error ( w , "tsidp: method not allowed" , http . StatusMethodNotAllowed )
return
}
if r . FormValue ( "grant_type" ) != "authorization_code" {
http . Error ( w , "tsidp: grant_type not supported" , http . StatusBadRequest )
return
}
code := r . FormValue ( "code" )
if code == "" {
http . Error ( w , "tsidp: code is required" , http . StatusBadRequest )
return
}
s . mu . Lock ( )
ar , ok := s . code [ code ]
if ok {
delete ( s . code , code )
}
s . mu . Unlock ( )
if ! ok {
http . Error ( w , "tsidp: code not found" , http . StatusBadRequest )
return
}
2024-08-08 14:46:45 +00:00
if err := ar . allowRelyingParty ( r , s . lc ) ; err != nil {
2023-11-13 23:18:58 +00:00
log . Printf ( "Error allowing relying party: %v" , err )
http . Error ( w , err . Error ( ) , http . StatusForbidden )
return
}
if ar . redirectURI != r . FormValue ( "redirect_uri" ) {
http . Error ( w , "tsidp: redirect_uri mismatch" , http . StatusBadRequest )
return
}
signer , err := s . oidcSigner ( )
if err != nil {
log . Printf ( "Error getting signer: %v" , err )
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
jti := rands . HexString ( 32 )
who := ar . remoteUser
// TODO(maisem): not sure if this is the right thing to do
userName , _ , _ := strings . Cut ( ar . remoteUser . UserProfile . LoginName , "@" )
n := who . Node . View ( )
if n . IsTagged ( ) {
http . Error ( w , "tsidp: tagged nodes not supported" , http . StatusBadRequest )
return
}
now := time . Now ( )
_ , tcd , _ := strings . Cut ( n . Name ( ) , "." )
tsClaims := tailscaleClaims {
Claims : jwt . Claims {
Audience : jwt . Audience { ar . clientID } ,
Expiry : jwt . NewNumericDate ( now . Add ( 5 * time . Minute ) ) ,
ID : jti ,
IssuedAt : jwt . NewNumericDate ( now ) ,
Issuer : s . serverURL ,
NotBefore : jwt . NewNumericDate ( now ) ,
Subject : n . User ( ) . String ( ) ,
} ,
Nonce : ar . nonce ,
Key : n . Key ( ) ,
Addresses : n . Addresses ( ) ,
NodeID : n . ID ( ) ,
NodeName : n . Name ( ) ,
Tailnet : tcd ,
UserID : n . User ( ) ,
Email : who . UserProfile . LoginName ,
UserName : userName ,
}
if ar . localRP {
tsClaims . Issuer = s . loopbackURL
}
// Create an OIDC token using this issuer's signer.
token , err := jwt . Signed ( signer ) . Claims ( tsClaims ) . CompactSerialize ( )
if err != nil {
log . Printf ( "Error getting token: %v" , err )
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
at := rands . HexString ( 32 )
s . mu . Lock ( )
ar . validTill = now . Add ( 5 * time . Minute )
mak . Set ( & s . accessToken , at , ar )
s . mu . Unlock ( )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
if err := json . NewEncoder ( w ) . Encode ( oidcTokenResponse {
AccessToken : at ,
TokenType : "Bearer" ,
ExpiresIn : 5 * 60 ,
IDToken : token ,
} ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
}
}
type oidcTokenResponse struct {
IDToken string ` json:"id_token" `
TokenType string ` json:"token_type" `
AccessToken string ` json:"access_token" `
RefreshToken string ` json:"refresh_token" `
ExpiresIn int ` json:"expires_in" `
}
const (
oidcJWKSPath = "/.well-known/jwks.json"
oidcConfigPath = "/.well-known/openid-configuration"
)
func ( s * idpServer ) oidcSigner ( ) ( jose . Signer , error ) {
return s . lazySigner . GetErr ( func ( ) ( jose . Signer , error ) {
sk , err := s . oidcPrivateKey ( )
if err != nil {
return nil , err
}
return jose . NewSigner ( jose . SigningKey {
Algorithm : jose . RS256 ,
Key : sk . k ,
} , & jose . SignerOptions { EmbedJWK : false , ExtraHeaders : map [ jose . HeaderKey ] any {
jose . HeaderType : "JWT" ,
"kid" : fmt . Sprint ( sk . kid ) ,
} } )
} )
}
func ( s * idpServer ) oidcPrivateKey ( ) ( * signingKey , error ) {
return s . lazySigningKey . GetErr ( func ( ) ( * signingKey , error ) {
var sk signingKey
b , err := os . ReadFile ( "oidc-key.json" )
if err == nil {
if err := sk . UnmarshalJSON ( b ) ; err == nil {
return & sk , nil
} else {
log . Printf ( "Error unmarshaling key: %v" , err )
}
}
id , k := mustGenRSAKey ( 2048 )
sk . k = k
sk . kid = id
b , err = sk . MarshalJSON ( )
if err != nil {
log . Fatalf ( "Error marshaling key: %v" , err )
}
if err := os . WriteFile ( "oidc-key.json" , b , 0600 ) ; err != nil {
log . Fatalf ( "Error writing key: %v" , err )
}
return & sk , nil
} )
}
func ( s * idpServer ) serveJWKS ( w http . ResponseWriter , r * http . Request ) {
if r . URL . Path != oidcJWKSPath {
http . Error ( w , "tsidp: not found" , http . StatusNotFound )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
sk , err := s . oidcPrivateKey ( )
if err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
// TODO(maisem): maybe only marshal this once and reuse?
// TODO(maisem): implement key rotation.
je := json . NewEncoder ( w )
je . SetIndent ( "" , " " )
if err := je . Encode ( jose . JSONWebKeySet {
Keys : [ ] jose . JSONWebKey {
{
Key : sk . k . Public ( ) ,
Algorithm : string ( jose . RS256 ) ,
Use : "sig" ,
KeyID : fmt . Sprint ( sk . kid ) ,
} ,
} ,
} ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
}
return
}
// openIDProviderMetadata is a partial representation of
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata.
type openIDProviderMetadata struct {
Issuer string ` json:"issuer" `
AuthorizationEndpoint string ` json:"authorization_endpoint,omitempty" `
TokenEndpoint string ` json:"token_endpoint,omitempty" `
UserInfoEndpoint string ` json:"userinfo_endpoint,omitempty" `
JWKS_URI string ` json:"jwks_uri" `
ScopesSupported views . Slice [ string ] ` json:"scopes_supported" `
ResponseTypesSupported views . Slice [ string ] ` json:"response_types_supported" `
SubjectTypesSupported views . Slice [ string ] ` json:"subject_types_supported" `
ClaimsSupported views . Slice [ string ] ` json:"claims_supported" `
IDTokenSigningAlgValuesSupported views . Slice [ string ] ` json:"id_token_signing_alg_values_supported" `
// TODO(maisem): maybe add other fields?
// Currently we fill out the REQUIRED fields, scopes_supported and claims_supported.
}
type tailscaleClaims struct {
jwt . Claims ` json:",inline" `
Nonce string ` json:"nonce,omitempty" ` // the nonce from the request
Key key . NodePublic ` json:"key" ` // the node public key
Addresses views . Slice [ netip . Prefix ] ` json:"addresses" ` // the Tailscale IPs of the node
NodeID tailcfg . NodeID ` json:"nid" ` // the stable node ID
NodeName string ` json:"node" ` // name of the node
Tailnet string ` json:"tailnet" ` // tailnet (like tail-scale.ts.net)
// Email is the "emailish" value with an '@' sign. It might not be a valid email.
Email string ` json:"email,omitempty" ` // user emailish (like "alice@github" or "bob@example.com")
UserID tailcfg . UserID ` json:"uid,omitempty" `
// UserName is the local part of Email (without '@' and domain).
// It is a temporary (2023-11-15) hack during development.
// We should probably let this be configured via grants.
UserName string ` json:"username,omitempty" `
}
var (
openIDSupportedClaims = views . SliceOf ( [ ] string {
// Standard claims, these correspond to fields in jwt.Claims.
"sub" , "aud" , "exp" , "iat" , "iss" , "jti" , "nbf" , "username" , "email" ,
// Tailscale claims, these correspond to fields in tailscaleClaims.
"key" , "addresses" , "nid" , "node" , "tailnet" , "tags" , "user" , "uid" ,
} )
// As defined in the OpenID spec this should be "openid".
openIDSupportedScopes = views . SliceOf ( [ ] string { "openid" , "email" , "profile" } )
// We only support getting the id_token.
openIDSupportedReponseTypes = views . SliceOf ( [ ] string { "id_token" , "code" } )
// The type of the "sub" field in the JWT, which means it is globally unique identifier.
// The other option is "pairwise", which means the identifier is different per receiving 3p.
openIDSupportedSubjectTypes = views . SliceOf ( [ ] string { "public" } )
// The algo used for signing. The OpenID spec says "The algorithm RS256 MUST be included."
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
openIDSupportedSigningAlgos = views . SliceOf ( [ ] string { string ( jose . RS256 ) } )
)
func ( s * idpServer ) serveOpenIDConfig ( w http . ResponseWriter , r * http . Request ) {
if r . URL . Path != oidcConfigPath {
http . Error ( w , "tsidp: not found" , http . StatusNotFound )
return
}
ap , err := netip . ParseAddrPort ( r . RemoteAddr )
if err != nil {
log . Printf ( "Error parsing remote addr: %v" , err )
return
}
var authorizeEndpoint string
rpEndpoint := s . serverURL
2024-08-08 14:46:45 +00:00
if isFunnelRequest ( r ) {
authorizeEndpoint = fmt . Sprintf ( "%s/authorize/funnel" , s . serverURL )
} else if who , err := s . lc . WhoIs ( r . Context ( ) , r . RemoteAddr ) ; err == nil {
2023-11-13 23:18:58 +00:00
authorizeEndpoint = fmt . Sprintf ( "%s/authorize/%d" , s . serverURL , who . Node . ID )
} else if ap . Addr ( ) . IsLoopback ( ) {
rpEndpoint = s . loopbackURL
authorizeEndpoint = fmt . Sprintf ( "%s/authorize/localhost" , s . serverURL )
} else {
log . Printf ( "Error getting WhoIs: %v" , err )
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
je := json . NewEncoder ( w )
je . SetIndent ( "" , " " )
if err := je . Encode ( openIDProviderMetadata {
AuthorizationEndpoint : authorizeEndpoint ,
Issuer : rpEndpoint ,
JWKS_URI : rpEndpoint + oidcJWKSPath ,
UserInfoEndpoint : rpEndpoint + "/userinfo" ,
TokenEndpoint : rpEndpoint + "/token" ,
ScopesSupported : openIDSupportedScopes ,
ResponseTypesSupported : openIDSupportedReponseTypes ,
SubjectTypesSupported : openIDSupportedSubjectTypes ,
ClaimsSupported : openIDSupportedClaims ,
IDTokenSigningAlgValuesSupported : openIDSupportedSigningAlgos ,
} ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
}
}
2024-08-08 14:46:45 +00:00
// funnelClient represents an OIDC client/relying party that is accessing the
// IDP over Funnel.
type funnelClient struct {
ID string ` json:"client_id" `
Secret string ` json:"client_secret,omitempty" `
Name string ` json:"name,omitempty" `
RedirectURI string ` json:"redirect_uri" `
}
// /clients is a privileged endpoint that allows the visitor to create new
// Funnel-capable OIDC clients, so it is only accessible over the tailnet.
func ( s * idpServer ) serveClients ( w http . ResponseWriter , r * http . Request ) {
if isFunnelRequest ( r ) {
http . Error ( w , "tsidp: not found" , http . StatusNotFound )
return
}
path := strings . TrimPrefix ( r . URL . Path , "/clients/" )
if path == "new" {
s . serveNewClient ( w , r )
return
}
if path == "" {
s . serveGetClientsList ( w , r )
return
}
s . mu . Lock ( )
c , ok := s . funnelClients [ path ]
s . mu . Unlock ( )
if ! ok {
http . Error ( w , "tsidp: not found" , http . StatusNotFound )
return
}
switch r . Method {
case "DELETE" :
s . serveDeleteClient ( w , r , path )
case "GET" :
json . NewEncoder ( w ) . Encode ( & funnelClient {
ID : c . ID ,
Name : c . Name ,
Secret : "" ,
RedirectURI : c . RedirectURI ,
} )
default :
http . Error ( w , "tsidp: method not allowed" , http . StatusMethodNotAllowed )
}
}
func ( s * idpServer ) serveNewClient ( w http . ResponseWriter , r * http . Request ) {
if r . Method != "POST" {
http . Error ( w , "tsidp: method not allowed" , http . StatusMethodNotAllowed )
return
}
redirectURI := r . FormValue ( "redirect_uri" )
if redirectURI == "" {
http . Error ( w , "tsidp: must provide redirect_uri" , http . StatusBadRequest )
return
}
clientID := rands . HexString ( 32 )
clientSecret := rands . HexString ( 64 )
newClient := funnelClient {
ID : clientID ,
Secret : clientSecret ,
Name : r . FormValue ( "name" ) ,
RedirectURI : redirectURI ,
}
s . mu . Lock ( )
defer s . mu . Unlock ( )
mak . Set ( & s . funnelClients , clientID , & newClient )
if err := s . storeFunnelClientsLocked ( ) ; err != nil {
log . Printf ( "could not write funnel clients db: %v" , err )
http . Error ( w , "tsidp: could not write funnel clients to db" , http . StatusInternalServerError )
// delete the new client to avoid inconsistent state between memory
// and disk
delete ( s . funnelClients , clientID )
return
}
json . NewEncoder ( w ) . Encode ( newClient )
}
func ( s * idpServer ) serveGetClientsList ( w http . ResponseWriter , r * http . Request ) {
if r . Method != "GET" {
http . Error ( w , "tsidp: method not allowed" , http . StatusMethodNotAllowed )
return
}
s . mu . Lock ( )
redactedClients := make ( [ ] funnelClient , 0 , len ( s . funnelClients ) )
for _ , c := range s . funnelClients {
redactedClients = append ( redactedClients , funnelClient {
ID : c . ID ,
Name : c . Name ,
Secret : "" ,
RedirectURI : c . RedirectURI ,
} )
}
s . mu . Unlock ( )
json . NewEncoder ( w ) . Encode ( redactedClients )
}
func ( s * idpServer ) serveDeleteClient ( w http . ResponseWriter , r * http . Request , clientID string ) {
if r . Method != "DELETE" {
http . Error ( w , "tsidp: method not allowed" , http . StatusMethodNotAllowed )
return
}
s . mu . Lock ( )
defer s . mu . Unlock ( )
if s . funnelClients == nil {
http . Error ( w , "tsidp: client not found" , http . StatusNotFound )
return
}
if _ , ok := s . funnelClients [ clientID ] ; ! ok {
http . Error ( w , "tsidp: client not found" , http . StatusNotFound )
return
}
deleted := s . funnelClients [ clientID ]
delete ( s . funnelClients , clientID )
if err := s . storeFunnelClientsLocked ( ) ; err != nil {
log . Printf ( "could not write funnel clients db: %v" , err )
http . Error ( w , "tsidp: could not write funnel clients to db" , http . StatusInternalServerError )
// restore the deleted value to avoid inconsistent state between memory
// and disk
s . funnelClients [ clientID ] = deleted
return
}
w . WriteHeader ( http . StatusNoContent )
}
// storeFunnelClientsLocked writes the current mapping of OIDC client ID/secret
// pairs for RPs that access the IDP over funnel. s.mu must be held while
// calling this.
func ( s * idpServer ) storeFunnelClientsLocked ( ) error {
var buf bytes . Buffer
if err := json . NewEncoder ( & buf ) . Encode ( s . funnelClients ) ; err != nil {
return err
}
return os . WriteFile ( funnelClientsFile , buf . Bytes ( ) , 0600 )
}
2023-11-13 23:18:58 +00:00
const (
minimumRSAKeySize = 2048
)
// mustGenRSAKey generates a new RSA key with the provided number of bits. It
// panics on failure. bits must be at least minimumRSAKeySizeBytes * 8.
func mustGenRSAKey ( bits int ) ( kid uint64 , k * rsa . PrivateKey ) {
if bits < minimumRSAKeySize {
panic ( "request to generate a too-small RSA key" )
}
kid = must . Get ( readUint64 ( crand . Reader ) )
k = must . Get ( rsa . GenerateKey ( crand . Reader , bits ) )
return
}
// readUint64 reads from r until 8 bytes represent a non-zero uint64.
func readUint64 ( r io . Reader ) ( uint64 , error ) {
for {
var b [ 8 ] byte
if _ , err := io . ReadFull ( r , b [ : ] ) ; err != nil {
return 0 , err
}
if v := binary . BigEndian . Uint64 ( b [ : ] ) ; v != 0 {
return v , nil
}
}
}
// rsaPrivateKeyJSONWrapper is the the JSON serialization
// format used by RSAPrivateKey.
type rsaPrivateKeyJSONWrapper struct {
Key string
ID uint64
}
type signingKey struct {
k * rsa . PrivateKey
kid uint64
}
func ( sk * signingKey ) MarshalJSON ( ) ( [ ] byte , error ) {
b := pem . Block {
Type : "RSA PRIVATE KEY" ,
Bytes : x509 . MarshalPKCS1PrivateKey ( sk . k ) ,
}
bts := pem . EncodeToMemory ( & b )
return json . Marshal ( rsaPrivateKeyJSONWrapper {
Key : base64 . URLEncoding . EncodeToString ( bts ) ,
ID : sk . kid ,
} )
}
func ( sk * signingKey ) UnmarshalJSON ( b [ ] byte ) error {
var wrapper rsaPrivateKeyJSONWrapper
if err := json . Unmarshal ( b , & wrapper ) ; err != nil {
return err
}
if len ( wrapper . Key ) == 0 {
return nil
}
b64dec , err := base64 . URLEncoding . DecodeString ( wrapper . Key )
if err != nil {
return err
}
blk , _ := pem . Decode ( b64dec )
k , err := x509 . ParsePKCS1PrivateKey ( blk . Bytes )
if err != nil {
return err
}
sk . k = k
sk . kid = wrapper . ID
return nil
}
// parseID takes a string input and returns a typed IntID T and true, or a zero
// value and false if the input is unhandled syntax or out of a valid range.
func parseID [ T ~ int64 ] ( input string ) ( _ T , ok bool ) {
if input == "" {
return 0 , false
}
i , err := strconv . ParseInt ( input , 10 , 64 )
if err != nil {
return 0 , false
}
if i < 0 {
return 0 , false
}
return T ( i ) , true
}
2024-08-08 14:46:45 +00:00
// isFunnelRequest checks if an HTTP request is coming over Tailscale Funnel.
func isFunnelRequest ( r * http . Request ) bool {
// If we're funneling through the local tailscaled, it will set this HTTP
// header.
if r . Header . Get ( "Tailscale-Funnel-Request" ) != "" {
return true
}
// If the funneled connection is from tsnet, then the net.Conn will be of
// type ipn.FunnelConn.
netConn := r . Context ( ) . Value ( ctxConn { } )
// if the conn is wrapped inside TLS, unwrap it
if tlsConn , ok := netConn . ( * tls . Conn ) ; ok {
netConn = tlsConn . NetConn ( )
}
if _ , ok := netConn . ( * ipn . FunnelConn ) ; ok {
return true
}
return false
}