Skip to content
cespedesPublic

About

OpenID Connect server

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

jambo

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.html
  • https://openid.net/specs/openid-connect-core-1_0.html

HTTP server endpoints

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

Workflow

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 ResponseTypeRedirect with 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.

Login sessions

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 client template value is set from Server.GetConnection in /auth and in /auth/login).
  • If a login form is submitted for a session that has been used, has expired or is unknown, /auth/login renders error.html with a message telling which of the three it is (without repeating the session identifier). /token always answers invalid_grant in all those cases, as RFC 6749 requires; with SetDebug(true) it logs the real reason.

Reconfiguring a Server at runtime

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.

Template values for one client

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.

Shared Signals Framework (SSF)

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_access makes /token also return a refresh token, so the receiver can keep calling the SSF management API long after the user's session ends (a standard OAuth2 grant_type=refresh_token flow, not SSF-specific).
  • AddSSFEventsSupported declares 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/stream management 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.

Other OpenID Connect providers

About

OpenID Connect server

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages