jambo is a Go package to build an OpenID Connect provider (OIDC server).
"Jambo" is also a Swahili word. It translates to "hello" or "hi". It's a common greeting used in East Africa, particularly in Tanzania and Kenya.
In order to create a OpenID Connect provider, you will have to create a new server, which is in turn a HTTP handler.
This is a complete example:
package main
import (
"net/http"
"github.com/cespedes/jambo"
)
func main() {
issuer := "https://example.com/oidc"
root := "/oidc"
s := jambo.NewServer(issuer, root)
clientID := "test-client"
clientSecret := "client-secret"
client := s.NewClient(clientID, clientSecret)
client.AddAllowedRedirectURIs("https://example.com/callback")
s.SetAuthenticator(func (req *jambo.Request) jambo.Response {
if req.Params["login"] == "admin" && req.Params["password"] == "secret" {
return jambo.Response{
Type: jambo.ResponseTypeLoginOK,
Login: "admin",
Name: "Charlie Root",
Claims: map[string]any{},
}
}
return jambo.Response{
Type: jambo.ResponseTypeLoginFailed,
Login: req.Params["login"],
}
})
http.ListenAndServe(":8080", s)
}It will create a HTTP server, listening to requests under root and providing all the necessary handlers for the OIDC provider.
The OpenID Connect specification is here:
https://openid.net/specs/openid-connect-discovery-1_0.htmlhttps://openid.net/specs/openid-connect-core-1_0.html
| endpoint | description |
|---|---|
/.well-known/openid-configuration |
OpenID Connect configuration |
/auth |
HTML page to ask for credentials |
POST /auth/login |
used by end users to send login information (password, OTP...) to the server |
POST /token |
used by clients to send the code (or a refresh token) and get id token and access token |
/keys |
get the list of keys used to sign the tokens |
/userinfo |
used by clients to get Claims from the access token |
/.well-known/ssf-configuration |
Shared Signals Framework (SSF) transmitter configuration |
/ssf/stream |
SSF stream management: create/read/update/delete (POST/GET/PATCH/PUT/DELETE) |
/ssf/status, /ssf/subjects:add, /ssf/subjects:remove, /ssf/verify |
rest of the SSF stream management API |
POST /ssf/poll/{stream_id} |
poll delivery: receivers pull pending Security Event Tokens from here |
We will assume Alice (a user) wants to connect to a GitLab instance (a client), which is configured to authenticate using Jambo, our OpenID Connect provider.
- Alice opens a web browser and goes to GitLab page (https://gitlab.example.com).
- GitLab redirects to Jambo's authentication page (https://jambo.example.com/auth), with query parameters specifying the client (GitLab) and the list of the required scopes.
- Jambo reads the query parameters and checks if the client exists and the URL is well-formed.
- Jambo creates a session for this connection and stores its state.
- Jambo parses a HTML template and offers it to Alice a login page (typically with a HTML form).
- Alice fills the user and password and presses "submit".
- The form is posted to the authentication page (https://jambo.example.com/auth/login).
- Jambo receives the request, checks if it comes from an active session, and calls the Authenticator function with all the parameters received from the form.
- The Authentication function checks the parameters and returns a "Login OK".
- Optionally, the Authenticator can ask for another step (for example a one-time
code) by returning
ResponseTypeRedirectwith the name of an HTML template to show, and the flow goes back to the previous step when that form is submitted. - Once the login is OK, Jambo redirects Alice to GitLab's "callback address"
- GitLab receives the request with a "code"
- GitLab connects to Jambo in background, sending the "code" and the "client secret".
- Jambo replies with an access token (also sent as id token): a signed JWT (JWS) whose claims (login, name, e-mail...) depend on the requested scopes.
- GitLab receives the response and sends Alice the GitLab page, already authenticated.
Each /auth request creates a session (identified by a random value that is
also the authorization code), kept in memory only:
- It can be used for 10 minutes. The code is single-use: once it has been redeemed
at
/token, it can't be used again. - Sessions that have expired or been used are not deleted right away: they are kept
for 24 hours after being created, so that an HTML template can still tell which
client they belonged to (the
clienttemplate value is set fromServer.GetConnectionin/authand in/auth/login). - If a login form is submitted for a session that has been used, has expired or is
unknown,
/auth/loginrenderserror.htmlwith a message telling which of the three it is (without repeating the session identifier)./tokenalways answersinvalid_grantin all those cases, as RFC 6749 requires; withSetDebug(true)it logs the real reason.
Server.NewClient(id, secret) and Server.RemoveClient(id) let a host
application apply a changed configuration -- e.g. reloaded from a config
file on SIGHUP -- to a Server that's already serving requests, without
restarting it:
client := s.NewClient(clientID, newSecret) // drops the old one under this id first, if any
client.AddAllowedRedirectURIs(newRedirectURIs...)
client.AddAllowedScopes(newScopes...)Calling NewClient again with an id that's already registered atomically
replaces that *Client; any refresh tokens or SSF streams already
created under that client id are untouched (they're keyed by the id
string, not by the *Client value) and remain reachable through the
newly configured client -- this is what you want when the config change
is still, conceptually, the same client.
Server.RemoveClient(id) is a hard delete instead: besides removing the
client itself, it also deletes that client id's refresh tokens and SSF
streams from Storage, precisely so that reusing the same id later (e.g.
a plain NewClient call for a completely unrelated client) starts clean
rather than silently inheriting whatever the previous occupant of that id
left behind. Existing tokens and Connections it already handed out keep
working until they'd next need the client looked up again, but no new
/auth or /token request will find it.
Server.ReplacePresentation(staticFS, templatesFS, templateArgs) does the
same for the other reloadable part of a Server's configuration -- static
files, HTML templates and template args -- letting a web_static/
web_templates/template-args change from a reloaded config apply without
restarting:
s.ReplacePresentation(newStaticFS, newTemplatesFS, newTemplateArgs)It always builds the new static files/templates/args from scratch
(layered on jambo's embedded defaults) before atomically swapping them
in -- templateArgs is replaced outright, not merged with whatever was
set before -- which is what makes it safe to call on a Server already
serving live traffic. staticFS/templatesFS may be nil to mean "just
the embedded defaults, no override." Client configuration is untouched --
combine it with NewClient/RemoveClient above for a full reload.
Besides the values given to ReplacePresentation, which every template
receives, each client can have values of its own, available to the templates
rendered during its logins:
client.AddTemplateArgs(map[string]string{"url": "https://gitlab.example.com/"})In the template this is just {{ .url }}. Values from a client replace the
Server-wide ones with the same name, and the values Jambo itself passes
(client, which is the client's id, and, depending on the page, session,
postURL, error...) take precedence over both. Like the rest of a client's
configuration, they start empty again when NewClient replaces the client.
Jambo can also act as an SSF transmitter (OpenID Shared Signals Framework
1.0), so a receiver such as Apple Business Manager can subscribe to
security events (e.g. "a session was revoked") instead of, or in addition
to, doing SSO through it. This is what Apple Business Manager's
federated-authentication setup requires when it asks for an "SSF
configuration URL" and the ssf.manage/ssf.read scopes.
To wire it up:
client := s.NewClient(clientID, clientSecret)
client.AddAllowedScopes("offline_access", "ssf.manage", "ssf.read")
client.AddSSFEventsSupported(jambo.EventCAEPSessionRevoked, jambo.EventCAEPCredentialChange)offline_accessmakes/tokenalso return a refresh token, so the receiver can keep calling the SSF management API long after the user's session ends (a standard OAuth2grant_type=refresh_tokenflow, not SSF-specific).AddSSFEventsSupporteddeclares which event types this client's streams may ever receive; a receiver requests a subset of these when it creates a stream through the/ssf/streammanagement API.
The receiver creates and manages its own stream (delivered by push or poll, its choice) using an OAuth access token obtained through the normal authorization code flow. To actually notify it of something, call:
s.EmitSecurityEvent(clientID, jambo.EventCAEPSessionRevoked, jambo.Subject{
Format: jambo.SubjectFormatEmail,
Email: "alice@example.com",
}, nil)which signs a Security Event Token and delivers it to every enabled stream of that client that requested the event type and has that subject registered.
Every stream also implicitly supports the built-in "verification" event
type, regardless of what the receiver asked for, and Jambo pushes one
right after creating the stream, without being asked: several real
receivers (Apple Business Manager among them) wait to receive this before
considering the stream successfully set up, rather than calling
/ssf/verify themselves.
Trust model for /ssf/subjects:add: a receiver registers which
subjects it wants events about itself, via its own ssf.manage-scoped
token -- Jambo does not check that the subject has ever actually
authenticated through that specific client, or has any other
relationship to it, before accepting the registration. Any client with
ssf.manage can therefore ask to be notified about any subject at all.
This is fine as long as ssf.manage is only ever granted, in the host
application's own client configuration, to receivers that are themselves
trusted with that scope of visibility (e.g. as of this writing, the only
place this is wired up grants it to a single, IT-managed client) --
but if a host application ever grants ssf.manage to more than one
client, each of those clients can silently monitor events for subjects
that have nothing to do with it. A host application that needs to
prevent that has to enforce it itself (e.g. by recording which subjects
have actually authenticated through a given client, and consulting that
before calling EmitSecurityEvent or before allowing subjects:add to
succeed for a subject outside that set) -- Jambo does not do this on its
own.
Refresh tokens and SSF streams (unlike the short-lived login state used
during SSO) are meant to outlive a process restart. Jambo keeps them in
memory by default (MemoryStorage), which is fine for development but
loses everything on restart; a production deployment should implement the
small Storage interface against its own datastore and
install it with Server.SetStorage before serving traffic.
- https://github.com/ory/hydra
- https://github.com/dexidp/dex
- https://github.com/zitadel/zitadel
- https://github.com/keycloak/keycloak (written in Java)
- https://github.com/goauthentik/authentik (Python / Javascript)