charbonnierg / caddy-nats

2 stars 0 forks source link

Auth Callout #1

Open gedw99 opened 11 months ago

gedw99 commented 11 months ago

Thanks for this interesting work. I got it running with no hassles . Great work btw !

Nats auth call-out would be pretty useful with caddy perhaps ?

I was thinking an out how to map a third party identity provider such as Apple or Google with a nats user.

there are a few caddy plugins that manage the third party IDP aspects like greenpau’s stuff at https://github.com/greenpau/caddy-security

charbonnierg commented 11 months ago

Thanks for the review 😊 I'm going to publish another branch soon, which is much cleaner IMO, with auth callout supported.

There will be an interface for auth callout:

type AuthCallout interface {
    Handle(request *jwt.AuthorizationRequestClaims) (*jwt.AuthorizationResponseClaims, error)
}

As well as a caddy module namespace to implement custom interfaces. For example:

package deny_auth_callout

// Register caddy module
func init() {
    caddy.RegisterModule(new(AlwaysDenyAuthCallout))
}

// Define your auth callout struct
type AlwaysDenyAuthCallout struct {}

// Implement caddy.Module interface
func (AlwaysDenyAuthCallout) CaddyModule() caddy.ModuleInfo {
    return caddy.ModuleInfo{
        ID:  "nats.auth_callout.always_deny",
        New: func() caddy.Module { return new(AlwaysDenyAuthCallout) },
    }
}

// Implement AuthCallout interface
func (c *AlwaysDenyAuthCallout) Handle(request *jwt.AuthorizationRequestClaims) (*jwt.AuthorizationResponseClaims, error)  {
    return nil, errors.New("access denied")
}

The JSON configuration should be something like:

{
    "apps": {
        "nats_server": {
            "auth_service": {
                "handler": {
                    "always_deny": {}  // Add options according to module used
                },
            }
        }
    }
}

This auth service would connect to the server using an in process connection. Regarding accounts or credentials used to connect, I guess this could be options for the modules for now. Comments or advices are welcomed 😊

But I'd also like to ask you how you plan to use NATS auth callout in practice ?

TBH, I still fail to understand how it should be used. From what I understood from playing with it:

I'm thinking that at least in server mode, it should be possible to let clients authenticate against OIDC Provider, obtain a ID Token, and let clients connect to NATS py providing:

And let auth callout service sign a JWT only if user is authorized according to ID Token claims + some policies stored on auth callout service side. But since this is server mode, I understand that all users will access the same account ? (again maybe I'm wrong).

I'm quite lost with this new feature 😅 So if you could share how you plan to use auth callout that would be a great help ! And also feel free to provide insights or ideas regarding the caddy module stuff !

gedw99 commented 11 months ago

Hey @charbonnierg

Thanks for the discussion.

I don't think I know myself right now. I had a look last night but did not make much h progress. The docs and example for this stuff are pretty non existent - I guess because they only added it recently.

About why I think it's a good idea. Well there ar tons of use cases where you want users to be able to login with their Apple or Google account and have that map ( if you know what I mean ) to a NATS A account.


I am planning to use Pocketable because its galang and has Apple and other single signal working apparently.

https://github.com/pocketbase/pocketbase https://pocketbase.io

Apple sign: https://github.com/pocketbase/pocketbase/issues/202

It's very easily to run. A single golang import and you get the Web GUI included.


I was thinking about integrating it with Caddy as a Module, and it only needs a single import. The Sqlite DB can use the non CGO version.

There is some info here... https://github.com/pocketbase/pocketbase/discussions/3308

Being a single gulling import it should not be too hard.


Then I was going to get it deploying with fly.io

Here is one example that includes fly.io docker: https://github.com/milimyname/lifets-cloud

The Sqlite DB is then scaled out to allow regions and can also scale to zero also. Basically their LifeFS system keeps all the Sqlite instances synced.

https://fly.io/blog/litefs-cloud/

https://github.com/superfly/flyctl is the golang cli to use fly.io

charbonnierg commented 11 months ago

About why I think it's a good idea. Well there ar tons of use cases where you want users to be able to login with their Apple or Google account and have that map ( if you know what I mean ) to a NATS A account.

That's exactly where I have difficulties to imagine the whole thing. What do you think your users will present to the NATS server in their connect info ? Will it be their own user/pass (I guess not) or will it be an ID token as a password ? Or maybe something else ?


Thanks for the links by the way, didn't know pocketbase, but it seems interesting. I'm well aware of fly.io and I also think that's a great way to deploy stuff

gedw99 commented 11 months ago

Yeah I know what you mean.. I just don't know yet.

It's one of those things where it's a "don't know what you don't know".

I think getting pocket base and caddy going is a good first step since pocket base has the auth stuff sorted already.

then see if we can join it up with ants,

charbonnierg commented 11 months ago

Here's what I can do with my latest local build:

{
    "logging": {
        "logs": {
            "default": {
                "level": "DEBUG"
            }
        }
    },
    "apps": {
        "tls": {
            "automation": {
                "policies": [
                    {
                        "subjects": [
                            "local.quara-dev.com",
                            "leaf.local.quara-dev.com",
                            "ws.local.quara-dev.com",
                            "metrics.local.quara-dev.com"
                        ],
                        "issuers": [
                            {
                                "module": "internal"
                            }
                        ],
                        "disable_ocsp_stapling": true
                    }
                ]
            }
        },
        "nats": {
            "auth_service": {
                "auth_signing_key": "SAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
                "handler": {
                    "module": "always_deny"
                }
            },
            "server": {
                "name": "test",
                "tags": {
                    "env": "demo"
                },
                "debug": true,
                "http_port": 8222,
                "tls": {
                    "subjects": [
                        "local.quara-dev.com"
                    ]
                },
                "authorization": {
                    "users": [
                        {
                            "user": "auth",
                            "password": "auth"
                        }
                    ],
                    "auth_callout": {
                        "issuer": "ABZMXJGERX2OQDNDVOVZA7QW3QQVMZ5WERHZXVREUWCNXQQ2BZZFLVDT",
                        "auth_users": [
                            "auth"
                        ]
                    }
                },
                "jetstream": {},
                "mqtt": {},
                "leafnode": {
                    "tls": {
                        "subjects": [
                            "leaf.local.quara-dev.com"
                        ]
                    }
                },
                "websocket": {
                    "tls": {
                        "subjects": [
                            "ws.local.quara-dev.com"
                        ]
                    }
                },
                "metrics": {
                    "connz": true,
                    "connz_detailed": true
                }
            }
        },
        "http": {
            "servers": {
                "https": {
                    "listen": [
                        ":80",
                        ":443"
                    ],
                    "routes": [
                        {
                            "match": [
                                {
                                    "host": [
                                        "metrics.local.quara-dev.com"
                                    ]
                                }
                            ],
                            "handle": [
                                {
                                    "handler": "metrics"
                                }
                            ],
                            "terminal": true
                        }
                    ],
                    "metrics": {}
                }
            }
        }
    }
}

as you can see, I'm using auth callout, and I'm using a handler which is always_deny.

This handler is implemented like that:

package auth_callout

import (
    "errors"

    "github.com/caddyserver/caddy/v2"
    "github.com/nats-io/jwt/v2"
)

func init() {
    caddy.RegisterModule(DenyAuthCallout{})
}

// A minimal auth callout handler that always denies access.
type DenyAuthCallout struct{}

func (DenyAuthCallout) CaddyModule() caddy.ModuleInfo {
    return caddy.ModuleInfo{
        ID:  "nats.auth_callout.always_deny",
        New: func() caddy.Module { return new(DenyAuthCallout) },
    }
}

func (a *DenyAuthCallout) Handle(request *jwt.AuthorizationRequestClaims) (*jwt.AuthorizationResponseClaims, error) {
    return nil, errors.New("access denied")
}

so you can see it's quite trivial to add new auth callout handlers modules.

And here the output when I try to connect:

 nats pub foo bar --server wss://ws.local.quara-dev.com:10443
2023/10/12 20:19:36.274 DEBUG   events  event   {"name": "tls_get_certificate", "id": "a8654f13-2187-4a9e-ba8c-5488607f46cf", "origin": "tls", "data": {"client_hello":{"CipherSuites":[49195,49199,49196,49200,52393,52392,49161,49171,49162,49172,156,157,47,53,49170,10,4865,4866,4867],"ServerName":"ws.local.quara-dev.com","SupportedCurves":[29,23,24,25],"SupportedPoints":"AA==","SignatureSchemes":[2052,1027,2055,2053,2054,1025,1281,1537,1283,1539,513,515],"SupportedProtos":null,"SupportedVersions":[772,771],"Conn":{}}}}
2023/10/12 20:19:36.274 DEBUG   tls.handshake   choosing certificate    {"identifier": "ws.local.quara-dev.com", "num_choices": 1}
2023/10/12 20:19:36.274 DEBUG   tls.handshake   default certificate selection results   {"identifier": "ws.local.quara-dev.com", "subjects": ["ws.local.quara-dev.com"], "managed": true, "issuer_key": "local", "hash": "2a114715fdc41043b926191db76eaf7544dd0e6edeea5df6bf2186c907789853"}
2023/10/12 20:19:36.274 DEBUG   tls.handshake   matched certificate in cache    {"remote_ip": "127.0.0.1", "remote_port": "59162", "subjects": ["ws.local.quara-dev.com"], "managed": true, "expiration": "2023/10/13 05:23:19.000", "hash": "2a114715fdc41043b926191db76eaf7544dd0e6edeea5df6bf2186c907789853"}
2023/10/12 20:19:36.283 DEBUG   nats.server     127.0.0.1:59162 - wid:6 - Client connection created
2023/10/12 20:19:36.284 DEBUG   nats.auth_callout       Received authorization request  {"payload": "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJhdWQiOiJuYXRzLWF1dGhvcml6YXRpb24tcmVxdWVzdCIsImV4cCI6MTY5NzE0MTk3OSwianRpIjoiSVBJWFU2RUpGQU82TEdVTlJQVFdXSlI1UVVVRFRMQ0k2WDM0Rkk0WTdKTERPVUxNS0NNQSIsImlhdCI6MTY5NzE0MTk3NiwiaXNzIjoiTkNFN0hJUVM0R1lPRE5IQVM3UEM2UlhSVzM1SlBMT0hNQUhLNkZJNUJRRFlFRkZRWVJMSVJYT1MiLCJzdWIiOiJBQlpNWEpHRVJYMk9RRE5EVk9WWkE3UVczUVFWTVo1V0VSSFpYVlJFVVdDTlhRUTJCWlpGTFZEVCIsIm5hdHMiOnsic2VydmVyX2lkIjp7Im5hbWUiOiJ0ZXN0IiwiaG9zdCI6IjAuMC4wLjAiLCJpZCI6Ik5DRTdISVFTNEdZT0ROSEFTN1BDNlJYUlczNUpQTE9ITUFISzZGSTVCUURZRUZGUVlSTElSWE9TIiwidmVyc2lvbiI6IjIuMTAuMiIsInRhZ3MiOlsiZW52OmRlbW8iXX0sInVzZXJfbmtleSI6IlVERDdFRURDTVlHQjZUQVdCRFRCSVZJQTQ0M082U1kyMzUyV0FUR1VXRkFKSFZVVUhIS1lPQVJBIiwiY2xpZW50X2luZm8iOnsiaG9zdCI6IjEyNy4wLjAuMSIsImlkIjo2LCJuYW1lIjoiTkFUUyBDTEkgVmVyc2lvbiAwLjAuMzUiLCJraW5kIjoiQ2xpZW50IiwidHlwZSI6IndlYnNvY2tldCJ9LCJjb25uZWN0X29wdHMiOnsibmFtZSI6Ik5BVFMgQ0xJIFZlcnNpb24gMC4wLjM1IiwibGFuZyI6ImdvIiwidmVyc2lvbiI6IjEuMTkuMCIsInByb3RvY29sIjoxfSwiY2xpZW50X3RscyI6eyJ2ZXJzaW9uIjoiMS4zIiwiY2lwaGVyIjoiVExTX0FFU18xMjhfR0NNX1NIQTI1NiJ9LCJ0eXBlIjoiYXV0aG9yaXphdGlvbl9yZXF1ZXN0IiwidmVyc2lvbiI6Mn19.DWAmTeme38ynFOJTGxMsuYBUprsDcvN1nB0i7YY3bICyajb6fJsZDbFjsNWmgGATyQOB65NZ4jfssAcw_jBXDw"}
2023/10/12 20:19:36.284 WARN    nats.server     Auth callout service returned an error: access denied
2023/10/12 20:19:36.284 ERROR   nats.server     127.0.0.1:59162 - wid:6 - authentication error - User ""
2023/10/12 20:19:36.284 DEBUG   nats.server     127.0.0.1:59162 - wid:6 - Client connection closed: Authentication Failure

We can see that the error is received by NATS server so user is denied.

I will share my work tonight or tomorrow, but I still need to clean up some things

charbonnierg commented 11 months ago

I pushed my work on this branch: https://github.com/charbonnierg/caddy-nats/tree/rewrite. I got an example running with 2 different accounts and a caddy module providing auth callout service which issues JWT according to the username provided in connect options. This can work with 0 credentials in config file, because auth callout account is created on startup 🎉 I think I'll try creating an auth callout module accepting an ID token from Azure OIDC provider:

The module will accept a configuration describing authorization policies (for example, azure users which are members of that group can access that account, etc.). In the end I think something like https://github.com/greenpau/go-authcrunch/tree/main would be useful (this module https://github.com/greenpau/go-authcrunch/blob/main/pkg/acl/acl.go)

gedw99 commented 11 months ago

wow that was quick. will def check it out...

charbonnierg commented 11 months ago

After some additional work, I have somehow managed to authorize NATS users according to an encrypted OAuth2 session state provided as password.

I was thinking that tools such as https://github.com/oauth2-proxy/oauth2-proxy are already pretty good at letting users authenticate against IdP. Basically, they handle the ID token ad Access token creation, and then set an encrypted session state cookie which can be used in subsequent requests.

I had to fork the oauth2-proxy project in order to easily create a caddy module so that I can decode session state from other caddy modules (this requires access to a shared secret key). This way, my NATS authorization callout module can decode session state set by the oauth2_proxy moduke, and issue authorization response when session is valid. Biggest limitation at the moment is that there is no policy for account authorization, any user which has a valid session state gets a JWT for any valid account specified as username in connect opts. Also, even if all provider options from oauth2-proxy are supported, not all global options are supported at the moment.

Example flow:

The next caddy handler would be a web application, which can use javascript connect to NATS on behalf of user using user/pass (this is pseudo-code):

 const nc = await connect({
      "wss://localhost",
      user: "APP",  // The target account 
      pass: browser.cookies.get("_oauth2_proxy"),  // The session state cookie which exist/is valid only when successfully authenticated
});

I did not test it using a browser, but I tested in the CLI by passing the cookie values like that:

.\nats.exe pub foo bar --server tls://localhost --user APP --password "_oauth2_proxy_0=[cookie value],_oauth2_proxy_0=[cookie value]"

Note: I have two cookies because Azure provider returns claims which are too big for a single cookie (4kb), but other most other providers claims fit into one cookie.

sorry if this comment is a mess, everything described here is quite new to me😅

This is very much a hack at the moment, but I'm starting to better understand how to use this auth callout system.


BTW, regarding embedding pocketbase as a caddy module, it might be tricky to do as an HTTP middleware, but may be easy to do as an app with a listener indeed.

charbonnierg commented 11 months ago

It's much better now, hopefully if you have access to an Azure AD (now called Microsoft Entra ID), or any provider supported by oauth2-proxy, you will be able to try the example (with a little bit more changes required if you're not using azure).

Now it's possible to match incoming auth requests according to client info or connect options (or both), and delegate authorization to a specific auth callout module. There are 3 modules implemented at the moment:

nc = await nats.connect( {"servers": "wss://localhost:10443", "user": "APP", "pass": document.cookie} )

await nc.publish("test")

gedw99 commented 11 months ago

nice info about using ants and auth call out !

https://www.youtube.com/watch?v=Ds71axJRFm0&ab_channel=Synadia

plus some other goodies

gedw99 commented 11 months ago

I don't use azure btw. Maybe I can test it with some other auth system ..