rp

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 11, 2026 License: BSD-3-Clause Imports: 19 Imported by: 2

README

rp

Log people in with OpenID Connect from Go. This is the relying-party half: discovery, the authorization code with PKCE, the ID token checked against the login it answers, a loopback login for native applications (RFC 8252), and refresh for one resource at a time (RFC 8707). Pure Go, CGO_ENABLED=0.

go-authn/oidc is the other half, the resource-server one: it verifies a token that arrives, and gets nobody one. It is kept small on purpose, so the half that logs people in lives here.

p, err := rp.Discover(ctx, nil, "https://login.example.org")
c, err := rp.NewClient(ctx, p, rp.Config{ClientID: "portal", Secret: secret,
	RedirectURI: "https://files.example.org/callback", Scopes: []string{"openid", "offline_access"}})

f := rp.NewFlow()                         // keep it where only this browser brings it back
http.Redirect(w, r, c.AuthCodeURL(f, nil), http.StatusFound)
// at the redirect URI, once state is checked:
tok, id, err := c.Exchange(ctx, code, f)  // the ID token verified, with this login's nonce

// a token for one server; the refresh token keeps the whole grant
at, err := c.Refresh(ctx, tok.RefreshToken, rp.For{Resource: "https://fs-paris.example.org/"})

A native application logs in through the system browser:

c, tok, id, err := rp.Loopback(ctx, p, rp.Config{ClientID: "console"}, rp.OpenBrowser, nil)

What it always does

PKCE with S256 for every client, confidential ones too (RFC 9700 §2.1.1). A provider whose discovery document lists its PKCE methods without S256 is refused: it would ignore the challenge.
state and nonce 32 random bytes each per login. The ID token must carry the login's nonce, and with several audiences its azp must be this client (OIDC Core §3.1.3.7).
discovery the document must name the issuer it was fetched for, exactly (OIDC Discovery §4.3); every endpoint must be https or loopback; an end-session endpoint in the clear is dropped.
client authentication client_secret_basic, both parts form-encoded first (RFC 6749 §2.3.1). With no secret the client is public and names itself in the body (§3.2.1), as a native app must.
refresh resource and/or scope say what the token is for. IsInvalidGrant tells a grant that has ended from a refusal that has not (invalid_target).

The loopback login (RFC 8252)

  • It listens on 127.0.0.1, or [::1] when there is no IPv4 loopback, on a port the system picks (§7.3). It never listens on localhost (§8.3) or on any other interface.
  • It opens the system browser, never a web view (§8.12), and redirects to http://127.0.0.1:<port>/callback. The provider must accept any port for a loopback redirect; go-authn/bridge does.
  • A request on that port whose state is not this login's gets a 400 and is ignored: any local process can reach the port, so only the provider's answer ends the wait. An answer naming another issuer (RFC 9207), or carrying an error, ends it with that error.
  • The client is public: a secret shipped inside an application is no secret (§8.5). Its refresh tokens rotate (RFC 9700 §4.14.2), so refresh with one of them at a time.

Tests

go test -race ./...

The tests run against an in-process OpenID provider that is as strict as a real one:

  • exact redirect URIs, or any loopback port for a public client;
  • S256 only;
  • client_secret_basic for a confidential client, client_id in the body for a public one;
  • rotating refresh tokens with reuse detection;
  • resource becomes the only audience.

The loopback login is driven like a browser would: the authorization URL, the provider's redirect to the listener, and a local process knocking with another state first.

Each check was removed in turn, and a test failed each time:

  • state;
  • iss;
  • listening on loopback only;
  • nonce;
  • azp;
  • the issuer match at discovery;
  • S256 at discovery;
  • a public client's client_id;
  • an S256 rather than a plain challenge.

Removing the empty-ID-token check changes nothing, because go-authn/oidc refuses an empty token anyway; that check stays for its message.

Licence

BSD 3-Clause.

Documentation

Overview

Package rp logs people in with OpenID Connect: it is the relying-party half that go-authn/oidc deliberately leaves out.

p, err := rp.Discover(ctx, http.DefaultClient, "https://login.example.org")
c, err := rp.NewClient(ctx, p, rp.Config{ClientID: "console", RedirectURI: redirect})
f := rp.NewFlow()
http.Redirect(w, r, c.AuthCodeURL(f, nil), http.StatusFound)
// ... at the redirect URI, with the flow found again:
tok, id, err := c.Exchange(ctx, code, f)
// ... later, a token for one resource server (RFC 8707 §2.2):
at, err := c.Refresh(ctx, tok.RefreshToken, rp.For{Resource: "https://fs-a.example.org/"})

What it always does, because each is a defence somebody once skipped:

  • PKCE with S256 (RFC 7636), for confidential clients too (RFC 9700 §2.1.1), and a provider that publishes its methods without S256 is refused at discovery: it would ignore the challenge.
  • A state and a nonce per login. The ID token is verified -- signature, issuer, audience, times, by go-authn/oidc -- and must carry this login's nonce; with several audiences, azp must be this client.
  • The discovery document must name the issuer it was fetched for, exactly (OIDC Discovery §4.3), and every endpoint must be https or loopback.

A native application uses Loopback, which runs the whole login through the system browser and a listener on 127.0.0.1 (RFC 8252). Such a client is public: a secret shipped inside an application is no secret (RFC 8252 §8.5).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Challenge

func Challenge(verifier string) string

Challenge is the S256 code challenge of verifier (RFC 7636 §4.2).

func IsInvalidGrant

func IsInvalidGrant(err error) bool

IsInvalidGrant says whether err is the provider ending the grant.

func Loopback

func Loopback(ctx context.Context, p *Provider, cfg Config, open func(string) error, extra url.Values) (*Client, *Token, *oidc.Token, error)

Loopback logs a person in from a native application, as RFC 8252 says to:

  • it listens on the loopback IP literal, 127.0.0.1, or [::1] when there is no IPv4 loopback, on a port the system picks (§7.3); never on "localhost" (§8.3), and on no other interface;
  • it opens the authorization URL in the system browser with open -- never a web view inside the application (§8.12) -- and PKCE protects the code on its way back (§6);
  • it waits for the redirect at http://127.0.0.1:<port>/callback until ctx ends. A request whose state is not this login's is answered 400 and ignored -- any local process can reach the port -- so only the provider's answer ends the wait.

cfg.RedirectURI is set here; leave it empty. A native app is a public client (cfg.Secret empty, §8.5); go-authn/bridge accepts any loopback port for a client registered with http://127.0.0.1/callback. extra adds authorization parameters, such as RFC 8707 resources.

It returns the client (to refresh with), the tokens, and the verified ID token.

func OpenBrowser

func OpenBrowser(u string) error

OpenBrowser opens u in the system's default browser.

Types

type Client

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

Client logs people in at one provider.

func NewClient

func NewClient(ctx context.Context, p *Provider, cfg Config) (*Client, error)

NewClient is a client of p. It reads the provider's keys now, to verify ID tokens with.

func (*Client) AuthCodeURL

func (c *Client) AuthCodeURL(f Flow, extra url.Values) string

AuthCodeURL is where to send the browser. extra adds parameters, such as RFC 8707 resource indicators or a prompt.

func (*Client) EndSessionURL

func (c *Client) EndSessionURL(postLogout string) string

EndSessionURL is where to send a browser to end the session at the provider too (OIDC RP-Initiated Logout §2), or "" when it has no such endpoint.

func (*Client) Exchange

func (c *Client) Exchange(ctx context.Context, code string, f Flow) (*Token, *oidc.Token, error)

Exchange trades the code the redirect brought for tokens, and verifies the ID token against the flow's nonce.

func (*Client) Provider

func (c *Client) Provider() *Provider

Provider is the provider this client logs in at.

func (*Client) Refresh

func (c *Client) Refresh(ctx context.Context, refreshToken string, f For) (*Token, error)

Refresh buys an access token with a refresh token. A provider that rotates refresh tokens returns a new one, and revokes the whole grant when a retired one comes back (RFC 9700 §4.14.2): keep the new one, and never refresh twice at once with the same.

func (*Client) VerifyID

func (c *Client) VerifyID(ctx context.Context, raw, nonce string) (*oidc.Token, error)

VerifyID checks an ID token: signature, issuer, audience (this client), times -- go-authn/oidc -- and the nonce of the login it answers, so that an ID token from another login cannot be played into this one (OIDC Core §3.1.3.7). With several audiences, azp must be this client.

type Config

type Config struct {
	ClientID string
	// Secret is a confidential client's, sent with client_secret_basic
	// (RFC 6749 §2.3.1). Empty is a PUBLIC client: its client_id goes in the
	// token request instead, as a native app's must (RFC 8252 §8.5).
	Secret      string
	RedirectURI string
	// Scopes asked for at the login. Default openid. A refresh token usually
	// needs offline_access, or the provider's equivalent.
	Scopes []string
}

Config is a client of a provider.

type Error

type Error struct {
	Code        string `json:"error"`
	Description string `json:"error_description"`
}

Error is the provider's refusal (RFC 6749 §5.2). invalid_grant means the grant is over and the person has to log in again; invalid_target, that a resource was refused (RFC 8707 §2).

func (*Error) Error

func (e *Error) Error() string

type Flow

type Flow struct {
	State, Nonce, Verifier string
}

A Flow is one login in progress: what the redirect must be checked against and the code exchanged with. Keep it where only this browser can bring it back -- a sealed cookie, or the memory of the native app that started it.

func NewFlow

func NewFlow() Flow

NewFlow is a login with a fresh state, nonce and PKCE verifier: 32 bytes each from the system's generator (RFC 7636 §4.1: 43 characters, 256 bits).

type For

type For struct {
	Resource string
	Scope    string
}

For names what a refreshed token is for: a resource server (RFC 8707 §2.2: the token is for that one, the refresh token keeps the whole grant), and/or a scope, for a provider that sets the audience from a scope.

type Provider

type Provider struct {
	Issuer        string
	AuthURL       string
	TokenURL      string
	EndSessionURL string // "" when the provider has none, or one in the clear
	// contains filtered or unexported fields
}

Provider is what discovery says about an OpenID provider.

func Discover

func Discover(ctx context.Context, hc *http.Client, issuer string) (*Provider, error)

Discover reads issuer's /.well-known/openid-configuration.

type Token

type Token struct {
	AccessToken  string
	RefreshToken string // "" when none was issued, or the old one stays good
	IDToken      string
	Expiry       time.Time
}

Token is a token endpoint's answer (RFC 6749 §5.1, OIDC Core §3.1.3.3).

Jump to

Keyboard shortcuts

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