obligator

package module
v0.0.0-...-8fc6dd1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 29, 2024 License: MIT Imports: 32 Imported by: 3

README

Introduction

obligator is a relatively simple and opinionated OpenID Connect (OIDC) Provider (OP) server designed for self-hosters.

Hacker News discussion here.

Motivation

There are lots of great open source OIDC servers out there (see comparison). I made obligator because I needed a specific combination of features I didn't find in any of the others. Here's a brief list. See the feature explanation section for more detailed information.

  • Simple to deploy and manage. Static executable and either flat-file or sqlite storage
  • Support for anonymous OAuth2 client auth
  • Authenticate to multiple domains at once
  • Passwordless email login
  • Configurable at runtime with an API
  • Support for forward auth
  • Support for trusted headers
  • Support for upstream social login providers (GitLab, GitHub, Google, etc)

Design

The overarching philosophy of obligator is that identities are built on email. Email isn't perfect, but it's the globally unique federated identity we have that works today.

Thus the purpose of obligator is to validate that a user controls an email address as simply as possible, and communicate that to the application the user is attempting to log in to. Validation can either be done directly through SMTP, or delegated to upstream OIDC (and some plain OAuth2) providers.

Running it

Here's a fairly complete JSON storage file (obligator_storage.json). Note that I call it "storage" and not "config" because it's not static, and more like a simple database. obligator will update it at runtime if new values are provided through the API.

{
  "root_uri": "https://example.com",
  "login_key_name": "obligator_login_key",
  "oauth2_providers": [
    {
      "id": "google",
      "name": "Google",
      "uri": "https://accounts.google.com",
      "client_id": "<google oauth2 client_id>",
      "client_secret": "<google oauth2 client_secret>",
      "openid_connect": true
    },
    {
      "id": "lastlogin",
      "name": "LastLogin.io",
      "uri": "https://lastlogin.io",
      "client_id": "https://example.com",
      "client_secret": "",
      "openid_connect": true
    }
  ],
  "smtp": {
    "server": "smtp.fastmail.com",
    "username": "<smtp-username>",
    "password": "<smtp-password>",
    "port": 587,
    "sender": "auth@example.com",
    "sender_name": "Example"
  },
  "jwks": "<generated at first startup if empty>",
  "users": [
    {
      "email": "user1@example.com"
    },
    {
      "email": "user2@example.com"
    }
  ],
  "public": false
}

If you're already using docker, it's the easiest way to get started with obligator:

mkdir obligator_docker/
cp obligator_storage.json obligator_docker/

docker run --user $(id -u):$(id -g) --rm -it -v $PWD/obligator_docker:/data -v $PWD/obligator_docker:/api -p 1616:1616 anderspitman/obligator:latest -storage-dir /data -api-socket-dir /api -root-uri example.com -port 1616

You can also download static executables for various platforms from the releases page.

Using the API

Currently the API is only offered through unix sockets. This reduces the chance that it accidentally gets exposed, which is important because it's not authenticated in any way.

There's not any documentation, and the API is in flux, so refer to the source code for usage.

Here's an example assuming you ran the docker command above:

curl --unix obligator_docker/obligator_api.sock dummy-domain/oauth2-providers

See here for more info on using curl over unix sockets.

Support

Community support is provided on the IndieBits forums.

Feature explanation

Anonymous OAuth2 auth

Normally in OAuth2 (and therefore OIDC), an app (client) is required to pre-register with the provider. This can create a lot of friction, especially if you're self-hosting an open source application. App developers are forced to either share a single client ID for all their users (and share their client secret, which essentially makes it pointless), or each user must separately register their instance.

Instead, obligator takes essentially the approach described here. Any OAuth2 client can anonymously authenticate with an obligator instance, with the client_id equal to the domain of the client, and client_secret left blank. Security is maintained through the following means:

  • Only approved email addresses are permitted unless public: true is set in the config.
  • The client_id URI must be a prefix of the redirect_uri, and the client_id is displayed to the user when consenting to the login. This guarantees that the user approves the ID token to be sent to the domain shown. Note that this can actually be more secure than pre-registration. There have been attacks in the past where users were tricked into authorizing apps because the pre-registered information looked convincing. By forcing the user to decide whether they trust the actual domain where the ID token will be sent, and not displaying any sort of logo which can be faked, security is improved.

Note that some servers implement OIDC Dynamic Client Registration, which is an official specification to accomplish some of the same goals as anonymous auth. When an initial access token is not required (notably the case with Ory), this can result in a very similar experience from the user perspective. The main problem is that client apps must support dynamic client registration, and many don't. Anonymous auth does not require any special features on the client side.

Multi-domain authentication

Have you ever noticed when you login to Gmail on a new computer that you're also automatically logged in to YouTube? How does this work when Gmail is on google.com and youtube.com doesn't have any access to the cookies or localstorage of google.com?

The answer is that when you log in on accounts.google.com, it makes a quick redirect to youtube.com with a URL parameter to also set up the cookies there. I also want this functionality for all the domains protected by my OIDC server so I'm building it into obligator.

Passwordless email login

In line with the philosophy above, email reigns supreme in obligator. Since passwords are relatively difficult to use securely, the way to add an email identity is to send a confirmation code to the email address.

Demo

There's a public instance of obligator running at https://lastlogin.io (discovery doc at https://lastlogin.io/.well-known/openid-configuration). You can use it with any OIDC client. Just set the client_id to a prefix of the redirect_uri the client application uses when making the authorization request. I like to use https://openidconnect.net/ for ad-hoc testing, like so:

  1. Click on "Configuration" on the right side
  2. Enter the discovery document URL, ie https://lastlogin.io/.well-known/openid-configuration for LastLogin
  3. Click "Use Discovery Document". It should populate most of the fields
  4. Set the client_id to https://openidconnect.net/. This is a prefix of the redirect_uri that openidconnect.net uses, which is https://openidconnect.net/callback
  5. You can leave the client_secret as it is or remove it.
  6. Click "Save", then "Start" to begin the flow.

The official OpenID conformance suite is also excellent for testing OIDC servers.

Comparison is the thief of joy

Software is rarely about right vs wrong, but rather tradeoffs. This table is intended to help compare tradeoffs of different servers. It's also very incomplete and probably incorrect in many cases. If you have a correction, please submit an issue or leave a comment on the Google sheet here which is where it's generated from.

It's generated using the excellent https://tabletomarkdown.com

obligator Portier Rauthy Authelia Authentik KeyCloak Vouch oauth2-proxy Dex Ory Stack Zitadel Casdoor Kanidm
Simple
Anonymous auth
Multi-domain auth ✅ (planned)
Passwordless email login
HTTP API
Forward auth
Trusted header auth ✅ (planned)
Upstream OIDC/OAuth2 ❌ (partial)
SAML Needs coding
LDAP Needs coding
MFA
Standalone reverse proxy
Admin GUI
Dyanmic client registration
Passkey support
Vanity stars stars stars stars stars stars stars stars stars stars stars stars stars
Language Go Rust Rust Go Python Java Go Go Go Go Go Go Rust
Dependencies 5 21 73 49 54 16 36 36 58 81 68 116
Lines of code ~4000 ~9500 ~59000 ~148000 ~247000 ~869000 ~5500 ~54000 ~63500 ~330000 ~603000 ~113000 ~239000

Lines of code were calculated using tokei, and last updated on 2024-04-21.

Ory is calculated using Hydra + Oathkeeper + Kratos, per this

Documentation

Index

Constants

View Source
const EmailValidationsPerTimeLimit = 12

const RateLimitTime = 10 * time.Minute

View Source
const RateLimitTime = 24 * time.Hour

Variables

This section is empty.

Functions

func AuthUri

func AuthUri(serverUri string, authReq *OAuth2AuthRequest) string

func GenerateJWK

func GenerateJWK() (jwk.Key, error)

func GeneratePKCECodeChallenge

func GeneratePKCECodeChallenge(verifier string) string

func GeneratePKCEData

func GeneratePKCEData() (string, string, error)

func GetProfile

func GetProfile(provider *OAuth2Provider, accessToken string) (string, string, error)

func Hash

func Hash(input string) string

Types

type AddIdentityEmailHandler

type AddIdentityEmailHandler struct {
	// contains filtered or unexported fields
}

func NewAddIdentityEmailHandler

func NewAddIdentityEmailHandler(storage Storage, db *Database, cluster *Cluster, tmpl *template.Template, behindProxy bool, geoDb *ip2location.DB) *AddIdentityEmailHandler

func (*AddIdentityEmailHandler) ServeHTTP

func (*AddIdentityEmailHandler) StartEmailValidation

func (h *AddIdentityEmailHandler) StartEmailValidation(email, rootUri, magicLink string, identities []*Identity) error

type AddIdentityGamlHandler

type AddIdentityGamlHandler struct {
	// contains filtered or unexported fields
}

func NewAddIdentityGamlHandler

func NewAddIdentityGamlHandler(storage Storage, cluster *Cluster, tmpl *template.Template) *AddIdentityGamlHandler

func (*AddIdentityGamlHandler) ServeHTTP

type AddIdentityOauth2Handler

type AddIdentityOauth2Handler struct {
	// contains filtered or unexported fields
}

func NewAddIdentityOauth2Handler

func NewAddIdentityOauth2Handler(storage Storage, oauth2MetaMan *OAuth2MetadataManager) *AddIdentityOauth2Handler

func (*AddIdentityOauth2Handler) ServeHTTP

type Api

type Api struct {
	// contains filtered or unexported fields
}

func NewApi

func NewApi(storage Storage, dir string, oauth2MetaMan *OAuth2MetadataManager) (*Api, error)

func (*Api) AddUser

func (a *Api) AddUser(user User) error

func (*Api) GetUsers

func (a *Api) GetUsers() ([]User, error)

func (*Api) SetOAuth2Provider

func (a *Api) SetOAuth2Provider(prov OAuth2Provider) error

type Cluster

type Cluster struct {
	// contains filtered or unexported fields
}

func NewCluster

func NewCluster() *Cluster

func (*Cluster) GetLocalId

func (c *Cluster) GetLocalId() string

func (*Cluster) LocalId

func (c *Cluster) LocalId() string

func (*Cluster) PrimaryHost

func (c *Cluster) PrimaryHost() (string, error)

TODO: currently hits filesystem for every request. Might be able to listen for primary change events and only update periodically

func (*Cluster) RedirectOrForward

func (c *Cluster) RedirectOrForward(host string, w http.ResponseWriter, r *http.Request) bool

type Database

type Database struct {
	// contains filtered or unexported fields
}

func NewDatabase

func NewDatabase(path string) (*Database, error)

func (*Database) AddEmailValidationRequest

func (s *Database) AddEmailValidationRequest(requesterId, email string) error

func (*Database) GetEmailValidationCounts

func (s *Database) GetEmailValidationCounts(since time.Time) ([]*EmailValidationCount, error)

type EmailValidationCount

type EmailValidationCount struct {
	HashedRequesterId string
	Count             int
}

type GitHubEmail

type GitHubEmail struct {
	Email    string `json:"email"`
	Primary  bool   `json:"primary"`
	Verified bool   `json:"verified"`
}

type GitHubEmailResponse

type GitHubEmailResponse []*GitHubEmail

type Identity

type Identity struct {
	IdType        string `json:"id_type"`
	Id            string `json:"id"`
	ProviderName  string `json:"provider_name"`
	Email         string `json:"email"`
	EmailVerified bool   `json:"email_verified"`
}

type JsonStorage

type JsonStorage struct {
	DisplayName     string           `json:"display_name"`
	RootUri         string           `json:"root_uri"`
	Prefix          string           `json:"prefix"`
	OAuth2Providers []OAuth2Provider `json:"oauth2_providers"`
	Smtp            *SmtpConfig      `json:"smtp"`
	Jwks            jwk.Set          `json:"jwks"`
	Users           []User           `json:"users"`
	Public          bool             `json:"public"`
	// contains filtered or unexported fields
}

func NewJsonStorage

func NewJsonStorage(path string) (*JsonStorage, error)

func (*JsonStorage) AddJWKKey

func (s *JsonStorage) AddJWKKey(key jwk.Key)

func (*JsonStorage) CreateUser

func (s *JsonStorage) CreateUser(user User) error

func (*JsonStorage) GetDisplayName

func (s *JsonStorage) GetDisplayName() string

func (*JsonStorage) GetJWKSet

func (s *JsonStorage) GetJWKSet() jwk.Set

func (*JsonStorage) GetOAuth2ProviderByID

func (s *JsonStorage) GetOAuth2ProviderByID(id string) (OAuth2Provider, error)

func (*JsonStorage) GetOAuth2Providers

func (s *JsonStorage) GetOAuth2Providers() ([]OAuth2Provider, error)

func (*JsonStorage) GetPrefix

func (s *JsonStorage) GetPrefix() string

func (*JsonStorage) GetPublic

func (s *JsonStorage) GetPublic() bool

func (*JsonStorage) GetRootUri

func (s *JsonStorage) GetRootUri() string

func (*JsonStorage) GetSmtpConfig

func (s *JsonStorage) GetSmtpConfig() (SmtpConfig, error)

func (*JsonStorage) GetUsers

func (s *JsonStorage) GetUsers() ([]User, error)

func (*JsonStorage) Persist

func (s *JsonStorage) Persist()

func (*JsonStorage) SetDisplayName

func (s *JsonStorage) SetDisplayName(value string)

func (*JsonStorage) SetOauth2Provider

func (s *JsonStorage) SetOauth2Provider(provider OAuth2Provider) error

func (*JsonStorage) SetPrefix

func (s *JsonStorage) SetPrefix(prefix string)

func (*JsonStorage) SetRootUri

func (s *JsonStorage) SetRootUri(rootUri string) error

type Login

type Login struct {
	IdType       string `json:"id_type"`
	Id           string `json:"id"`
	ProviderName string `json:"provider_name"`
	Timestamp    string `json:"ts"`
}

type OAuth2AuthRequest

type OAuth2AuthRequest struct {
	ClientId      string `json:"client_id"`
	RedirectUri   string `json:"redirect_uri"`
	Scope         string `json:"scope"`
	State         string `json:"state"`
	ResponseType  string `json:"response_type"`
	CodeChallenge string `json:"code_challenge"`
}

func ParseAuthRequest

func ParseAuthRequest(w http.ResponseWriter, r *http.Request) (*OAuth2AuthRequest, error)

type OAuth2MetadataManager

type OAuth2MetadataManager struct {
	// contains filtered or unexported fields
}

func NewOAuth2MetadataManager

func NewOAuth2MetadataManager(storage Storage) *OAuth2MetadataManager

func (*OAuth2MetadataManager) GetKeyset

func (m *OAuth2MetadataManager) GetKeyset(providerId string) (jwk.Set, error)

func (*OAuth2MetadataManager) GetMeta

func (m *OAuth2MetadataManager) GetMeta(providerId string) (*OAuth2ServerMetadata, error)

func (*OAuth2MetadataManager) Update

func (m *OAuth2MetadataManager) Update() error

type OAuth2Provider

type OAuth2Provider struct {
	ID               string `json:"id"`
	Name             string `json:"name"`
	URI              string `json:"uri"`
	ClientID         string `json:"client_id" db:"client_id"`
	ClientSecret     string `json:"client_secret" db:"client_secret"`
	AuthorizationURI string `json:"authorization_uri,omitempty" db:"authorization_uri"`
	TokenURI         string `json:"token_uri,omitempty" db:"token_uri"`
	Scope            string `json:"scope,omitempty"`
	OpenIDConnect    bool   `json:"openid_connect" db:"supports_openid_connect"`
}

type OAuth2ServerMetadata

type OAuth2ServerMetadata struct {
	Issuer                            string   `json:"issuer,omitempty"`
	AuthorizationEndpoint             string   `json:"authorization_endpoint,omitempty"`
	TokenEndpoint                     string   `json:"token_endpoint,omitempty"`
	UserinfoEndpoint                  string   `json:"userinfo_endpoint,omitempty"`
	JwksUri                           string   `json:"jwks_uri,omitempty"`
	ScopesSupported                   []string `json:"scopes_supported,omitempty"`
	ResponseTypesSupported            []string `json:"response_types_supported,omitempty"`
	IdTokenSigningAlgValuesSupported  []string `json:"id_token_signing_alg_values_supported,omitempty"`
	CodeChallengeMethodsSupported     []string `json:"code_challenge_methods_supported"`
	SubjectTypesSupported             []string `json:"subject_types_supported"`
	RegistrationEndpoint              string   `json:"registration_endpoint"`
	TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
}

func GetOidcConfiguration

func GetOidcConfiguration(baseUrl string) (*OAuth2ServerMetadata, error)

type OAuth2TokenResponse

type OAuth2TokenResponse struct {
	AccessToken string `json:"access_token"`
	TokenType   string `json:"token_type"`
	ExpiresIn   int    `json:"expires_in"`
	IdToken     string `json:"id_token,omitempty"`
}

type OIDCHandler

type OIDCHandler struct {
	// contains filtered or unexported fields
}

func NewOIDCHandler

func NewOIDCHandler(storage Storage, tmpl *template.Template) *OIDCHandler

func (*OIDCHandler) ServeHTTP

func (h *OIDCHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

type OIDCRegistrationRequest

type OIDCRegistrationRequest struct {
	RedirectUris []string `json:"redirect_uris"`
}

type OIDCRegistrationResponse

type OIDCRegistrationResponse struct {
	ClientId string `json:"client_id"`
}

type ObligatorMux

type ObligatorMux struct {
	// contains filtered or unexported fields
}

func NewObligatorMux

func NewObligatorMux(behindProxy bool) *ObligatorMux

func (*ObligatorMux) Handle

func (s *ObligatorMux) Handle(p string, h http.Handler)

func (*ObligatorMux) HandleFunc

func (s *ObligatorMux) HandleFunc(p string, f func(w http.ResponseWriter, r *http.Request))

func (*ObligatorMux) ServeHTTP

func (s *ObligatorMux) ServeHTTP(w http.ResponseWriter, r *http.Request)

type PendingLogin

type PendingLogin struct {
	Email     string
	ExpiresAt time.Time
	RemoteIp  string
}

type PendingShare

type PendingShare struct {
	Identities []*Identity         `json:"identities"`
	Logins     map[string][]*Login `json:"logins"`
	ExpiresAt  time.Time
}

type QrHandler

type QrHandler struct {
	// contains filtered or unexported fields
}

func NewQrHandler

func NewQrHandler(storage Storage, cluster *Cluster, tmpl *template.Template) *QrHandler

func (*QrHandler) ServeHTTP

func (h *QrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

type QrTemplateData

type QrTemplateData struct {
	DisplayName  string
	RootUri      string
	Identities   []*Identity
	QrKey        string
	InstanceId   string
	ErrorMessage string
}

type Server

type Server struct {
	Config ServerConfig
	Mux    *ObligatorMux
	// contains filtered or unexported fields
}

func NewServer

func NewServer(conf ServerConfig) *Server

func (*Server) AddUser

func (s *Server) AddUser(user User) error

func (*Server) AuthDomains

func (s *Server) AuthDomains() []string

func (*Server) AuthUri

func (s *Server) AuthUri(authReq *OAuth2AuthRequest) string

func (*Server) GetUsers

func (s *Server) GetUsers() ([]User, error)

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

func (*Server) SetOAuth2Provider

func (s *Server) SetOAuth2Provider(prov OAuth2Provider) error

func (*Server) Start

func (s *Server) Start() error

func (*Server) Validate

func (s *Server) Validate(r *http.Request) (*Validation, error)

type ServerConfig

type ServerConfig struct {
	Port         int
	RootUri      string
	AuthDomains  []string
	Prefix       string
	StorageDir   string
	DatabaseDir  string
	ApiSocketDir string
	BehindProxy  bool
	DisplayName  string
	GeoDbPath    string
}

type SmtpConfig

type SmtpConfig struct {
	Server     string `json:"server,omitempty"`
	Username   string `json:"username,omitempty"`
	Password   string `json:"password,omitempty"`
	Port       int    `json:"port,omitempty"`
	Sender     string `json:"sender,omitempty"`
	SenderName string `json:"sender_name,omitempty"`
}

type Storage

type Storage interface {
	GetRootUri() string
	GetPrefix() string
	SetRootUri(string) error
	GetUsers() ([]User, error)
	CreateUser(User) error
	GetOAuth2Providers() ([]OAuth2Provider, error)
	GetOAuth2ProviderByID(string) (OAuth2Provider, error)
	SetOauth2Provider(OAuth2Provider) error
	GetPublic() bool
	GetSmtpConfig() (SmtpConfig, error)
	GetJWKSet() jwk.Set
	GetDisplayName() string
	SetDisplayName(string)
}

type User

type User struct {
	Email string `json:"email"`
}

type UserinfoResponse

type UserinfoResponse struct {
	Sub   string `json:"sub"`
	Email string `json:"email"`
}

type Validation

type Validation struct {
	Id     string `json:"id"`
	IdType string `json:"id_type"`
}

Directories

Path Synopsis
cmd

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL