Open gedw99 opened 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:
in server mode, users can either:
in operator mode, it seems that user must still provide a JWT (we can get rid of the need for an nkey using accounts configured with bearer tokens), and this JWT must be signed by auth account** (maybe I'm wrong ?), else request is sent to $G
account and I don't know how to implement an auth callout service listening on $G
account in operator mode (I don't even know if it's possible).
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 !
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
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
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,
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
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)
wow that was quick. will def check it out...
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:
https://localhost
oauth2_proxy
caddy module (http handler) intercepts the request and see that there is no session state in cookiehttps://localhost
, oauth2_proxy
set the session state cookie and forward to next caddy handlerThe 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.
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:
const nats = await import("https://cdn.jsdelivr.net/npm/nats.ws@1.18.0/esm/nats.js")
nc = await nats.connect( {"servers": "wss://localhost:10443", "user": "APP", "pass": document.cookie} )
await nc.publish("test")
nice info about using ants and auth call out !
https://www.youtube.com/watch?v=Ds71axJRFm0&ab_channel=Synadia
plus some other goodies
I don't use azure btw. Maybe I can test it with some other auth system ..
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