diff --git a/.codecov/codecov.yml b/.codecov/codecov.yml
index 8104953..b4394d2 100644
--- a/.codecov/codecov.yml
+++ b/.codecov/codecov.yml
@@ -19,4 +19,7 @@ parsers:
comment:
layout: "header, diff"
behavior: default
- require_changes: no
\ No newline at end of file
+ require_changes: no
+ignore:
+ - "example"
+ - "**/mock"
diff --git a/README.md b/README.md
index e841dad..b5cd075 100644
--- a/README.md
+++ b/README.md
@@ -11,12 +11,31 @@
## What Is It
-This project is a easy to use client (RP) and server (OP) implementation for the `OIDC` (Open ID Connect) standard written for `Go`.
+This project is an easy-to-use client (RP) and server (OP) implementation for the `OIDC` (OpenID Connect) standard written for `Go`.
The RP is certified for the [basic](https://www.certification.openid.net/plan-detail.html?public=true&plan=uoprP0OO8Z4Qo) and [config](https://www.certification.openid.net/plan-detail.html?public=true&plan=AYSdLbzmWbu9X) profile.
Whenever possible we tried to reuse / extend existing packages like `OAuth2 for Go`.
+## Basic Overview
+
+The most important packages of the library:
+
+/pkg
+ /client clients using the OP for retrieving, exchanging and verifying tokens
+ /rp definition and implementation of an OIDC Relying Party (client)
+ /rs definition and implementation of an OAuth Resource Server (API)
+ /op definition and implementation of an OIDC OpenID Provider (server)
+ /oidc definitions shared by clients and server
+
+/example
+ /api example of an api / resource server implementation using token introspection
+ /app web app / RP demonstrating authorization code flow using various authentication methods (code, PKCE, JWT profile)
+ /github example of the extended OAuth2 library, providing an HTTP client with a reuse token source
+ /service demonstration of JWT Profile Authorization Grant
+ /server example of an OpenID Provider implementation including some very basic login UI
+
+
## How To Use It
Check the `/example` folder where example code for different scenarios is located.
@@ -24,21 +43,22 @@ Check the `/example` folder where example code for different scenarios is locate
```bash
# start oidc op server
# oidc discovery http://localhost:9998/.well-known/openid-configuration
-CAOS_OIDC_DEV=1 go run github.com/caos/oidc/example/server/default
+go run github.com/caos/oidc/example/server
# start oidc web client
-CLIENT_ID=web CLIENT_SECRET=web ISSUER=http://localhost:9998/ SCOPES=openid PORT=5556 go run github.com/caos/oidc/example/client/app
+CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://localhost:9998/ SCOPES="openid profile" PORT=9999 go run github.com/caos/oidc/example/client/app
```
-- browser http://localhost:5556/login will redirect to op server
-- input id to login
-- redirect to client app display user info
+- open http://localhost:9999/login in your browser
+- you will be redirected to op server and the login UI
+- login with user `test-user` and password `verysecure`
+- the OP will redirect you to the client app, which displays the user info
## Features
-| | Code Flow | Implicit Flow | Hybrid Flow | Discovery | PKCE | Token Exchange | mTLS | JWT Profile | Refresh Token |
-|----------------|-----------|---------------|-------------|-----------|------|----------------|---------|-------------|---------------|
-| Relying Party | yes | no[^1] | no | yes | yes | partial | not yet | yes | yes |
-| OpenID Provider | yes | yes | not yet | yes | yes | not yet | not yet | yes | yes |
+| | Code Flow | Implicit Flow | Hybrid Flow | Discovery | PKCE | Token Exchange | mTLS | JWT Profile | Refresh Token |
+|------------------|-----------|---------------|-------------|-----------|------|----------------|---------|-------------|---------------|
+| Relying Party | yes | no[^1] | no | yes | yes | partial | not yet | yes | yes |
+| OpenID Provider | yes | yes | not yet | yes | yes | not yet | not yet | yes | yes |
### Resources
diff --git a/example/doc.go b/example/doc.go
index f7ec372..7212a7d 100644
--- a/example/doc.go
+++ b/example/doc.go
@@ -1 +1,11 @@
+/*
+Package example contains some example of the various use of this library:
+
+/api example of an api / resource server implementation using token introspection
+/app web app / RP demonstrating authorization code flow using various authentication methods (code, PKCE, JWT profile)
+/github example of the extended OAuth2 library, providing an HTTP client with a reuse token source
+/service demonstration of JWT Profile Authorization Grant
+/server example of an OpenID Provider implementation including some very basic login UI
+
+*/
package example
diff --git a/example/internal/mock/storage.go b/example/internal/mock/storage.go
deleted file mode 100644
index 570e8a5..0000000
--- a/example/internal/mock/storage.go
+++ /dev/null
@@ -1,344 +0,0 @@
-package mock
-
-import (
- "context"
- "crypto/rand"
- "crypto/rsa"
- "errors"
- "time"
-
- "gopkg.in/square/go-jose.v2"
-
- "github.com/caos/oidc/pkg/oidc"
- "github.com/caos/oidc/pkg/op"
-)
-
-type AuthStorage struct {
- key *rsa.PrivateKey
-}
-
-func NewAuthStorage() op.Storage {
- reader := rand.Reader
- bitSize := 2048
- key, err := rsa.GenerateKey(reader, bitSize)
- if err != nil {
- panic(err)
- }
- return &AuthStorage{
- key: key,
- }
-}
-
-type AuthRequest struct {
- ID string
- ResponseType oidc.ResponseType
- ResponseMode oidc.ResponseMode
- RedirectURI string
- Nonce string
- ClientID string
- CodeChallenge *oidc.CodeChallenge
- State string
-}
-
-func (a *AuthRequest) GetACR() string {
- return ""
-}
-
-func (a *AuthRequest) GetAMR() []string {
- return []string{
- "password",
- }
-}
-
-func (a *AuthRequest) GetAudience() []string {
- return []string{
- a.ClientID,
- }
-}
-
-func (a *AuthRequest) GetAuthTime() time.Time {
- return time.Now().UTC()
-}
-
-func (a *AuthRequest) GetClientID() string {
- return a.ClientID
-}
-
-func (a *AuthRequest) GetCode() string {
- return "code"
-}
-
-func (a *AuthRequest) GetCodeChallenge() *oidc.CodeChallenge {
- return a.CodeChallenge
-}
-
-func (a *AuthRequest) GetID() string {
- return a.ID
-}
-
-func (a *AuthRequest) GetNonce() string {
- return a.Nonce
-}
-
-func (a *AuthRequest) GetRedirectURI() string {
- return a.RedirectURI
- // return "http://localhost:5556/auth/callback"
-}
-
-func (a *AuthRequest) GetResponseType() oidc.ResponseType {
- return a.ResponseType
-}
-
-func (a *AuthRequest) GetResponseMode() oidc.ResponseMode {
- return a.ResponseMode
-}
-
-func (a *AuthRequest) GetScopes() []string {
- return []string{
- "openid",
- "profile",
- "email",
- }
-}
-
-func (a *AuthRequest) SetCurrentScopes(scopes []string) {}
-
-func (a *AuthRequest) GetState() string {
- return a.State
-}
-
-func (a *AuthRequest) GetSubject() string {
- return "sub"
-}
-
-func (a *AuthRequest) Done() bool {
- return true
-}
-
-var (
- a = &AuthRequest{}
- t bool
- c string
-)
-
-func (s *AuthStorage) Health(ctx context.Context) error {
- return nil
-}
-
-func (s *AuthStorage) CreateAuthRequest(_ context.Context, authReq *oidc.AuthRequest, _ string) (op.AuthRequest, error) {
- a = &AuthRequest{ID: "id", ClientID: authReq.ClientID, ResponseType: authReq.ResponseType, Nonce: authReq.Nonce, RedirectURI: authReq.RedirectURI, State: authReq.State}
- if authReq.CodeChallenge != "" {
- a.CodeChallenge = &oidc.CodeChallenge{
- Challenge: authReq.CodeChallenge,
- Method: authReq.CodeChallengeMethod,
- }
- }
- t = false
- return a, nil
-}
-func (s *AuthStorage) AuthRequestByCode(_ context.Context, code string) (op.AuthRequest, error) {
- if code != c {
- return nil, errors.New("invalid code")
- }
- return a, nil
-}
-func (s *AuthStorage) SaveAuthCode(_ context.Context, id, code string) error {
- if a.ID != id {
- return errors.New("not found")
- }
- c = code
- return nil
-}
-func (s *AuthStorage) DeleteAuthRequest(context.Context, string) error {
- t = true
- return nil
-}
-func (s *AuthStorage) AuthRequestByID(_ context.Context, id string) (op.AuthRequest, error) {
- if id != "id" || t {
- return nil, errors.New("not found")
- }
- return a, nil
-}
-func (s *AuthStorage) CreateAccessToken(ctx context.Context, request op.TokenRequest) (string, time.Time, error) {
- return "id", time.Now().UTC().Add(5 * time.Minute), nil
-}
-func (s *AuthStorage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) {
- return "id", "refreshToken", time.Now().UTC().Add(5 * time.Minute), nil
-}
-func (s *AuthStorage) TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (op.RefreshTokenRequest, error) {
- if refreshToken != c {
- return nil, errors.New("invalid token")
- }
- return a, nil
-}
-
-func (s *AuthStorage) TerminateSession(_ context.Context, userID, clientID string) error {
- return nil
-}
-
-func (s *AuthStorage) RevokeToken(ctx context.Context, token string, userID string, clientID string) *oidc.Error {
- return nil
-}
-
-func (s *AuthStorage) GetSigningKey(_ context.Context, keyCh chan<- jose.SigningKey) {
- keyCh <- jose.SigningKey{Algorithm: jose.RS256, Key: s.key}
-}
-func (s *AuthStorage) GetKey(_ context.Context) (*rsa.PrivateKey, error) {
- return s.key, nil
-}
-func (s *AuthStorage) GetKeySet(_ context.Context) (*jose.JSONWebKeySet, error) {
- pubkey := s.key.Public()
- return &jose.JSONWebKeySet{
- Keys: []jose.JSONWebKey{
- {Key: pubkey, Use: "sig", Algorithm: "RS256", KeyID: "1"},
- },
- }, nil
-}
-func (s *AuthStorage) GetKeyByIDAndUserID(_ context.Context, _, _ string) (*jose.JSONWebKey, error) {
- pubkey := s.key.Public()
- return &jose.JSONWebKey{Key: pubkey, Use: "sig", Algorithm: "RS256", KeyID: "1"}, nil
-}
-
-func (s *AuthStorage) GetClientByClientID(_ context.Context, id string) (op.Client, error) {
- if id == "none" {
- return nil, errors.New("not found")
- }
- var appType op.ApplicationType
- var authMethod oidc.AuthMethod
- var accessTokenType op.AccessTokenType
- var responseTypes []oidc.ResponseType
- if id == "web" {
- appType = op.ApplicationTypeWeb
- authMethod = oidc.AuthMethodBasic
- accessTokenType = op.AccessTokenTypeBearer
- responseTypes = []oidc.ResponseType{oidc.ResponseTypeCode}
- } else if id == "native" {
- appType = op.ApplicationTypeNative
- authMethod = oidc.AuthMethodNone
- accessTokenType = op.AccessTokenTypeBearer
- responseTypes = []oidc.ResponseType{oidc.ResponseTypeCode}
- } else {
- appType = op.ApplicationTypeUserAgent
- authMethod = oidc.AuthMethodNone
- accessTokenType = op.AccessTokenTypeJWT
- responseTypes = []oidc.ResponseType{oidc.ResponseTypeIDToken, oidc.ResponseTypeIDTokenOnly}
- }
- return &ConfClient{ID: id, applicationType: appType, authMethod: authMethod, accessTokenType: accessTokenType, responseTypes: responseTypes, devMode: false, grantTypes: []oidc.GrantType{oidc.GrantTypeCode}}, nil
-}
-
-func (s *AuthStorage) AuthorizeClientIDSecret(_ context.Context, id string, _ string) error {
- return nil
-}
-
-func (s *AuthStorage) SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserInfoSetter, _, _, _ string) error {
- return s.SetUserinfoFromScopes(ctx, userinfo, "", "", []string{})
-}
-func (s *AuthStorage) SetUserinfoFromScopes(ctx context.Context, userinfo oidc.UserInfoSetter, _, _ string, _ []string) error {
- userinfo.SetSubject(a.GetSubject())
- userinfo.SetAddress(oidc.NewUserInfoAddress("Test 789\nPostfach 2", "", "", "", "", ""))
- userinfo.SetEmail("test", true)
- userinfo.SetPhone("0791234567", true)
- userinfo.SetName("Test")
- userinfo.AppendClaims("private_claim", "test")
- return nil
-}
-func (s *AuthStorage) GetPrivateClaimsFromScopes(_ context.Context, _, _ string, _ []string) (map[string]interface{}, error) {
- return map[string]interface{}{"private_claim": "test"}, nil
-}
-
-func (s *AuthStorage) SetIntrospectionFromToken(ctx context.Context, introspect oidc.IntrospectionResponse, tokenID, subject, clientID string) error {
- if err := s.SetUserinfoFromScopes(ctx, introspect, "", "", []string{}); err != nil {
- return err
- }
- introspect.SetClientID(a.ClientID)
- return nil
-}
-
-func (s *AuthStorage) ValidateJWTProfileScopes(ctx context.Context, userID string, scope []string) ([]string, error) {
- return scope, nil
-}
-
-type ConfClient struct {
- applicationType op.ApplicationType
- authMethod oidc.AuthMethod
- responseTypes []oidc.ResponseType
- grantTypes []oidc.GrantType
- ID string
- accessTokenType op.AccessTokenType
- devMode bool
-}
-
-func (c *ConfClient) GetID() string {
- return c.ID
-}
-func (c *ConfClient) RedirectURIs() []string {
- return []string{
- "https://registered.com/callback",
- "http://localhost:9999/callback",
- "http://localhost:5556/auth/callback",
- "custom://callback",
- "https://localhost:8443/test/a/instructions-example/callback",
- "https://op.certification.openid.net:62064/authz_cb",
- "https://op.certification.openid.net:62064/authz_post",
- }
-}
-func (c *ConfClient) PostLogoutRedirectURIs() []string {
- return []string{}
-}
-
-func (c *ConfClient) LoginURL(id string) string {
- return "login?id=" + id
-}
-
-func (c *ConfClient) ApplicationType() op.ApplicationType {
- return c.applicationType
-}
-
-func (c *ConfClient) AuthMethod() oidc.AuthMethod {
- return c.authMethod
-}
-
-func (c *ConfClient) IDTokenLifetime() time.Duration {
- return 5 * time.Minute
-}
-func (c *ConfClient) AccessTokenType() op.AccessTokenType {
- return c.accessTokenType
-}
-func (c *ConfClient) ResponseTypes() []oidc.ResponseType {
- return c.responseTypes
-}
-func (c *ConfClient) GrantTypes() []oidc.GrantType {
- return c.grantTypes
-}
-
-func (c *ConfClient) DevMode() bool {
- return c.devMode
-}
-
-func (c *ConfClient) AllowedScopes() []string {
- return nil
-}
-
-func (c *ConfClient) RestrictAdditionalIdTokenScopes() func(scopes []string) []string {
- return func(scopes []string) []string {
- return scopes
- }
-}
-
-func (c *ConfClient) RestrictAdditionalAccessTokenScopes() func(scopes []string) []string {
- return func(scopes []string) []string {
- return scopes
- }
-}
-
-func (c *ConfClient) IsScopeAllowed(scope string) bool {
- return false
-}
-
-func (c *ConfClient) IDTokenUserinfoClaimsAssertion() bool {
- return false
-}
-
-func (c *ConfClient) ClockSkew() time.Duration {
- return 0
-}
diff --git a/example/server/default/default.go b/example/server/default/default.go
deleted file mode 100644
index 7edaf2e..0000000
--- a/example/server/default/default.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package main
-
-import (
- "context"
- "crypto/sha256"
- "html/template"
- "log"
- "net/http"
-
- "github.com/gorilla/mux"
-
- "github.com/caos/oidc/example/internal/mock"
- "github.com/caos/oidc/pkg/op"
-)
-
-func main() {
- ctx := context.Background()
- port := "9998"
- config := &op.Config{
- Issuer: "http://localhost:9998/",
- CryptoKey: sha256.Sum256([]byte("test")),
- }
- storage := mock.NewAuthStorage()
- handler, err := op.NewOpenIDProvider(ctx, config, storage, op.WithCustomTokenEndpoint(op.NewEndpoint("test")))
- if err != nil {
- log.Fatal(err)
- }
- router := handler.HttpHandler().(*mux.Router)
- router.Methods("GET").Path("/login").HandlerFunc(HandleLogin)
- router.Methods("POST").Path("/login").HandlerFunc(HandleCallback)
- server := &http.Server{
- Addr: ":" + port,
- Handler: router,
- }
- err = server.ListenAndServe()
- if err != nil {
- log.Fatal(err)
- }
- <-ctx.Done()
-}
-
-func HandleLogin(w http.ResponseWriter, r *http.Request) {
- tpl := `
-
-
-
-
- Login
-
-
-
-
- `
- t, err := template.New("login").Parse(tpl)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- err = t.Execute(w, nil)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
-}
-
-func HandleCallback(w http.ResponseWriter, r *http.Request) {
- r.ParseForm()
- client := r.FormValue("client")
- http.Redirect(w, r, "/authorize/callback?id="+client, http.StatusFound)
-}
diff --git a/example/server/internal/client.go b/example/server/internal/client.go
new file mode 100644
index 0000000..55425a3
--- /dev/null
+++ b/example/server/internal/client.go
@@ -0,0 +1,189 @@
+package internal
+
+import (
+ "time"
+
+ "github.com/caos/oidc/pkg/oidc"
+ "github.com/caos/oidc/pkg/op"
+)
+
+var (
+ //we use the default login UI and pass the (auth request) id
+ defaultLoginURL = func(id string) string {
+ return "/login/username?authRequestID=" + id
+ }
+
+ //clients to be used by the storage interface
+ clients = map[string]*Client{}
+)
+
+//Client represents the internal model of an OAuth/OIDC client
+//this could also be your database model
+type Client struct {
+ id string
+ secret string
+ redirectURIs []string
+ applicationType op.ApplicationType
+ authMethod oidc.AuthMethod
+ loginURL func(string) string
+ responseTypes []oidc.ResponseType
+ grantTypes []oidc.GrantType
+ accessTokenType op.AccessTokenType
+ devMode bool
+ idTokenUserinfoClaimsAssertion bool
+ clockSkew time.Duration
+}
+
+//GetID must return the client_id
+func (c *Client) GetID() string {
+ return c.id
+}
+
+//RedirectURIs must return the registered redirect_uris for Code and Implicit Flow
+func (c *Client) RedirectURIs() []string {
+ return c.redirectURIs
+}
+
+//PostLogoutRedirectURIs must return the registered post_logout_redirect_uris for sign-outs
+func (c *Client) PostLogoutRedirectURIs() []string {
+ return []string{}
+}
+
+//ApplicationType must return the type of the client (app, native, user agent)
+func (c *Client) ApplicationType() op.ApplicationType {
+ return c.applicationType
+}
+
+//AuthMethod must return the authentication method (client_secret_basic, client_secret_post, none, private_key_jwt)
+func (c *Client) AuthMethod() oidc.AuthMethod {
+ return c.authMethod
+}
+
+//ResponseTypes must return all allowed response types (code, id_token token, id_token)
+//these must match with the allowed grant types
+func (c *Client) ResponseTypes() []oidc.ResponseType {
+ return c.responseTypes
+}
+
+//GrantTypes must return all allowed grant types (authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:jwt-bearer)
+func (c *Client) GrantTypes() []oidc.GrantType {
+ return c.grantTypes
+}
+
+//LoginURL will be called to redirect the user (agent) to the login UI
+//you could implement some logic here to redirect the users to different login UIs depending on the client
+func (c *Client) LoginURL(id string) string {
+ return c.loginURL(id)
+}
+
+//AccessTokenType must return the type of access token the client uses (Bearer (opaque) or JWT)
+func (c *Client) AccessTokenType() op.AccessTokenType {
+ return c.accessTokenType
+}
+
+//IDTokenLifetime must return the lifetime of the client's id_tokens
+func (c *Client) IDTokenLifetime() time.Duration {
+ return 1 * time.Hour
+}
+
+//DevMode enables the use of non-compliant configs such as redirect_uris (e.g. http schema for user agent client)
+func (c *Client) DevMode() bool {
+ return c.devMode
+}
+
+//RestrictAdditionalIdTokenScopes allows specifying which custom scopes shall be asserted into the id_token
+func (c *Client) RestrictAdditionalIdTokenScopes() func(scopes []string) []string {
+ return func(scopes []string) []string {
+ return scopes
+ }
+}
+
+//RestrictAdditionalAccessTokenScopes allows specifying which custom scopes shall be asserted into the JWT access_token
+func (c *Client) RestrictAdditionalAccessTokenScopes() func(scopes []string) []string {
+ return func(scopes []string) []string {
+ return scopes
+ }
+}
+
+//IsScopeAllowed enables Client specific custom scopes validation
+//in this example we allow the CustomScope for all clients
+func (c *Client) IsScopeAllowed(scope string) bool {
+ return scope == CustomScope
+}
+
+//IDTokenUserinfoClaimsAssertion allows specifying if claims of scope profile, email, phone and address are asserted into the id_token
+//even if an access token if issued which violates the OIDC Core spec
+//(5.4. Requesting Claims using Scope Values: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims)
+//some clients though require that e.g. email is always in the id_token when requested even if an access_token is issued
+func (c *Client) IDTokenUserinfoClaimsAssertion() bool {
+ return c.idTokenUserinfoClaimsAssertion
+}
+
+//ClockSkew enables clients to instruct the OP to apply a clock skew on the various times and expirations
+//(subtract from issued_at, add to expiration, ...)
+func (c *Client) ClockSkew() time.Duration {
+ return c.clockSkew
+}
+
+//RegisterClients enables you to register clients for the example implementation
+//there are some clients (web and native) to try out different cases
+//add more if necessary
+func RegisterClients(registerClients ...*Client) {
+ for _, client := range registerClients {
+ clients[client.id] = client
+ }
+}
+
+//NativeClient will create a client of type native, which will always use PKCE and allow the use of refresh tokens
+//user-defined redirectURIs may include:
+// - http://localhost without port specification (e.g. http://localhost/auth/callback)
+// - custom protocol (e.g. custom://auth/callback)
+//(the examples will be used as default, if none is provided)
+func NativeClient(id string, redirectURIs ...string) *Client {
+ if len(redirectURIs) == 0 {
+ redirectURIs = []string{
+ "http://localhost/auth/callback",
+ "custom://auth/callback",
+ }
+ }
+ return &Client{
+ id: id,
+ secret: "", //no secret needed (due to PKCE)
+ redirectURIs: redirectURIs,
+ applicationType: op.ApplicationTypeNative,
+ authMethod: oidc.AuthMethodNone,
+ loginURL: defaultLoginURL,
+ responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode},
+ grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken},
+ accessTokenType: 0,
+ devMode: false,
+ idTokenUserinfoClaimsAssertion: false,
+ clockSkew: 0,
+ }
+}
+
+//WebClient will create a client of type web, which will always use Basic Auth and allow the use of refresh tokens
+//user-defined redirectURIs may include:
+// - http://localhost with port specification (e.g. http://localhost:9999/auth/callback)
+//(the example will be used as default, if none is provided)
+func WebClient(id, secret string, redirectURIs ...string) *Client {
+ if len(redirectURIs) == 0 {
+ redirectURIs = []string{
+ "http://localhost:9999/auth/callback",
+ }
+ }
+ return &Client{
+ id: id,
+ secret: secret,
+ redirectURIs: redirectURIs,
+ applicationType: op.ApplicationTypeWeb,
+ authMethod: oidc.AuthMethodBasic,
+ loginURL: defaultLoginURL,
+ responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode},
+ grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken},
+ accessTokenType: 0,
+ devMode: false,
+ idTokenUserinfoClaimsAssertion: false,
+ clockSkew: 0,
+ }
+}
diff --git a/example/server/internal/oidc.go b/example/server/internal/oidc.go
new file mode 100644
index 0000000..1b3bf52
--- /dev/null
+++ b/example/server/internal/oidc.go
@@ -0,0 +1,203 @@
+package internal
+
+import (
+ "time"
+
+ "golang.org/x/text/language"
+
+ "github.com/caos/oidc/pkg/op"
+
+ "github.com/caos/oidc/pkg/oidc"
+)
+
+const (
+ //CustomScope is an example for how to use custom scopes in this library
+ //(in this scenario, when requested, it will return a custom claim)
+ CustomScope = "custom_scope"
+
+ //CustomClaim is an example for how to return custom claims with this library
+ CustomClaim = "custom_claim"
+)
+
+type AuthRequest struct {
+ ID string
+ CreationDate time.Time
+ ApplicationID string
+ CallbackURI string
+ TransferState string
+ Prompt []string
+ UiLocales []language.Tag
+ LoginHint string
+ MaxAuthAge *time.Duration
+ UserID string
+ Scopes []string
+ ResponseType oidc.ResponseType
+ Nonce string
+ CodeChallenge *OIDCCodeChallenge
+
+ passwordChecked bool
+ authTime time.Time
+}
+
+func (a *AuthRequest) GetID() string {
+ return a.ID
+}
+
+func (a *AuthRequest) GetACR() string {
+ return "" //we won't handle acr in this example
+}
+
+func (a *AuthRequest) GetAMR() []string {
+ //this example only uses password for authentication
+ if a.passwordChecked {
+ return []string{"pwd"}
+ }
+ return nil
+}
+
+func (a *AuthRequest) GetAudience() []string {
+ return []string{a.ApplicationID} //this example will always just use the client_id as audience
+}
+
+func (a *AuthRequest) GetAuthTime() time.Time {
+ return a.authTime
+}
+
+func (a *AuthRequest) GetClientID() string {
+ return a.ApplicationID
+}
+
+func (a *AuthRequest) GetCodeChallenge() *oidc.CodeChallenge {
+ return CodeChallengeToOIDC(a.CodeChallenge)
+}
+
+func (a *AuthRequest) GetNonce() string {
+ return a.Nonce
+}
+
+func (a *AuthRequest) GetRedirectURI() string {
+ return a.CallbackURI
+}
+
+func (a *AuthRequest) GetResponseType() oidc.ResponseType {
+ return a.ResponseType
+}
+
+func (a *AuthRequest) GetResponseMode() oidc.ResponseMode {
+ return "" //we won't handle response mode in this example
+}
+
+func (a *AuthRequest) GetScopes() []string {
+ return a.Scopes
+}
+
+func (a *AuthRequest) GetState() string {
+ return a.TransferState
+}
+
+func (a *AuthRequest) GetSubject() string {
+ return a.UserID
+}
+
+func (a *AuthRequest) Done() bool {
+ return a.passwordChecked //this example only uses password for authentication
+}
+
+func PromptToInternal(oidcPrompt oidc.SpaceDelimitedArray) []string {
+ prompts := make([]string, len(oidcPrompt))
+ for _, oidcPrompt := range oidcPrompt {
+ switch oidcPrompt {
+ case oidc.PromptNone,
+ oidc.PromptLogin,
+ oidc.PromptConsent,
+ oidc.PromptSelectAccount:
+ prompts = append(prompts, oidcPrompt)
+ }
+ }
+ return prompts
+}
+
+func MaxAgeToInternal(maxAge *uint) *time.Duration {
+ if maxAge == nil {
+ return nil
+ }
+ dur := time.Duration(*maxAge) * time.Second
+ return &dur
+}
+
+func authRequestToInternal(authReq *oidc.AuthRequest, userID string) *AuthRequest {
+ return &AuthRequest{
+ CreationDate: time.Now(),
+ ApplicationID: authReq.ClientID,
+ CallbackURI: authReq.RedirectURI,
+ TransferState: authReq.State,
+ Prompt: PromptToInternal(authReq.Prompt),
+ UiLocales: authReq.UILocales,
+ LoginHint: authReq.LoginHint,
+ MaxAuthAge: MaxAgeToInternal(authReq.MaxAge),
+ UserID: userID,
+ Scopes: authReq.Scopes,
+ ResponseType: authReq.ResponseType,
+ Nonce: authReq.Nonce,
+ CodeChallenge: &OIDCCodeChallenge{
+ Challenge: authReq.CodeChallenge,
+ Method: string(authReq.CodeChallengeMethod),
+ },
+ }
+}
+
+type OIDCCodeChallenge struct {
+ Challenge string
+ Method string
+}
+
+func CodeChallengeToOIDC(challenge *OIDCCodeChallenge) *oidc.CodeChallenge {
+ if challenge == nil {
+ return nil
+ }
+ challengeMethod := oidc.CodeChallengeMethodPlain
+ if challenge.Method == "S256" {
+ challengeMethod = oidc.CodeChallengeMethodS256
+ }
+ return &oidc.CodeChallenge{
+ Challenge: challenge.Challenge,
+ Method: challengeMethod,
+ }
+}
+
+//RefreshTokenRequestFromBusiness will simply wrap the internal RefreshToken to implement the op.RefreshTokenRequest interface
+func RefreshTokenRequestFromBusiness(token *RefreshToken) op.RefreshTokenRequest {
+ return &RefreshTokenRequest{token}
+}
+
+type RefreshTokenRequest struct {
+ *RefreshToken
+}
+
+func (r *RefreshTokenRequest) GetAMR() []string {
+ return r.AMR
+}
+
+func (r *RefreshTokenRequest) GetAudience() []string {
+ return r.Audience
+}
+
+func (r *RefreshTokenRequest) GetAuthTime() time.Time {
+ return r.AuthTime
+}
+
+func (r *RefreshTokenRequest) GetClientID() string {
+ return r.ApplicationID
+}
+
+func (r *RefreshTokenRequest) GetScopes() []string {
+ return r.Scopes
+}
+
+func (r *RefreshTokenRequest) GetSubject() string {
+ return r.UserID
+}
+
+func (r *RefreshTokenRequest) SetCurrentScopes(scopes []string) {
+ r.Scopes = scopes
+}
diff --git a/example/server/internal/storage.go b/example/server/internal/storage.go
new file mode 100644
index 0000000..8d61050
--- /dev/null
+++ b/example/server/internal/storage.go
@@ -0,0 +1,553 @@
+package internal
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "fmt"
+ "math/big"
+ "time"
+
+ "github.com/google/uuid"
+ "golang.org/x/text/language"
+ "gopkg.in/square/go-jose.v2"
+
+ "github.com/caos/oidc/pkg/oidc"
+ "github.com/caos/oidc/pkg/op"
+)
+
+var (
+ //serviceKey1 is a public key which will be used for the JWT Profile Authorization Grant
+ //the corresponding private key is in the service-key1.json (for demonstration purposes)
+ serviceKey1 = &rsa.PublicKey{
+ N: func() *big.Int {
+ n, _ := new(big.Int).SetString("00f6d44fb5f34ac2033a75e73cb65ff24e6181edc58845e75a560ac21378284977bb055b1a75b714874e2a2641806205681c09abec76efd52cf40984edcf4c8ca09717355d11ac338f280d3e4c905b00543bdb8ee5a417496cb50cb0e29afc5a0d0471fd5a2fa625bd5281f61e6b02067d4fe7a5349eeae6d6a4300bcd86eef331", 16)
+ return n
+ }(),
+ E: 65537,
+ }
+)
+
+//storage implements the op.Storage interface
+//typically you would implement this as a layer on top of your database
+//for simplicity this example keeps everything in-memory
+type storage struct {
+ authRequests map[string]*AuthRequest
+ codes map[string]string
+ tokens map[string]*Token
+ clients map[string]*Client
+ users map[string]*User
+ services map[string]Service
+ refreshTokens map[string]*RefreshToken
+ signingKey signingKey
+}
+
+type signingKey struct {
+ ID string
+ Algorithm string
+ Key *rsa.PrivateKey
+}
+
+func NewStorage() *storage {
+ key, _ := rsa.GenerateKey(rand.Reader, 2048)
+ return &storage{
+ authRequests: make(map[string]*AuthRequest),
+ codes: make(map[string]string),
+ tokens: make(map[string]*Token),
+ refreshTokens: make(map[string]*RefreshToken),
+ clients: clients,
+ users: map[string]*User{
+ "id1": {
+ id: "id1",
+ username: "test-user",
+ password: "verysecure",
+ firstname: "Test",
+ lastname: "User",
+ email: "test-user@zitadel.ch",
+ emailVerified: true,
+ phone: "",
+ phoneVerified: false,
+ preferredLanguage: language.German,
+ },
+ },
+ services: map[string]Service{
+ "service": {
+ keys: map[string]*rsa.PublicKey{
+ "key1": serviceKey1,
+ },
+ },
+ },
+ signingKey: signingKey{
+ ID: "id",
+ Algorithm: "RS256",
+ Key: key,
+ },
+ }
+}
+
+//CheckUsernamePassword implements the `authenticate` interface of the login
+func (s *storage) CheckUsernamePassword(username, password, id string) error {
+ request, ok := s.authRequests[id]
+ if !ok {
+ return fmt.Errorf("request not found")
+ }
+
+ //for demonstration purposes we'll check on a static list with plain text password
+ //for real world scenarios, be sure to have the password hashed and salted (e.g. using bcrypt)
+ for _, user := range s.users {
+ if user.username == username && user.password == password {
+ //be sure to set user id into the auth request after the user was checked,
+ //so that you'll be able to get more information about the user after the login
+ request.UserID = user.id
+
+ //you will have to change some state on the request to guide the user through possible multiple steps of the login process
+ //in this example we'll simply check the username / password and set a boolean to true
+ //therefore we will also just check this boolean if the request / login has been finished
+ request.passwordChecked = true
+ return nil
+ }
+ }
+ return fmt.Errorf("username or password wrong")
+}
+
+//CreateAuthRequest implements the op.Storage interface
+//it will be called after parsing and validation of the authentication request
+func (s *storage) CreateAuthRequest(ctx context.Context, authReq *oidc.AuthRequest, userID string) (op.AuthRequest, error) {
+ //typically, you'll fill your internal / storage model with the information of the passed object
+ request := authRequestToInternal(authReq, userID)
+
+ //you'll also have to create a unique id for the request (this might be done by your database; we'll use a uuid)
+ request.ID = uuid.NewString()
+
+ //and save it in your database (for demonstration purposed we will use a simple map)
+ s.authRequests[request.ID] = request
+
+ //finally, return the request (which implements the AuthRequest interface of the OP
+ return request, nil
+}
+
+//AuthRequestByID implements the op.Storage interface
+//it will be called after the Login UI redirects back to the OIDC endpoint
+func (s *storage) AuthRequestByID(ctx context.Context, id string) (op.AuthRequest, error) {
+ request, ok := s.authRequests[id]
+ if !ok {
+ return nil, fmt.Errorf("request not found")
+ }
+ return request, nil
+}
+
+//AuthRequestByCode implements the op.Storage interface
+//it will be called after parsing and validation of the token request (in an authorization code flow)
+func (s *storage) AuthRequestByCode(ctx context.Context, code string) (op.AuthRequest, error) {
+ //for this example we read the id by code and then get the request by id
+ requestID, ok := s.codes[code]
+ if !ok {
+ return nil, fmt.Errorf("code invalid or expired")
+ }
+ return s.AuthRequestByID(ctx, requestID)
+}
+
+//SaveAuthCode implements the op.Storage interface
+//it will be called after the authentication has been successful and before redirecting the user agent to the redirect_uri
+//(in an authorization code flow)
+func (s *storage) SaveAuthCode(ctx context.Context, id string, code string) error {
+ //for this example we'll just save the authRequestID to the code
+ s.codes[code] = id
+ return nil
+}
+
+//DeleteAuthRequest implements the op.Storage interface
+//it will be called after creating the token response (id and access tokens) for a valid
+//- authentication request (in an implicit flow)
+//- token request (in an authorization code flow)
+func (s *storage) DeleteAuthRequest(ctx context.Context, id string) error {
+ //you can simply delete all reference to the auth request
+ delete(s.authRequests, id)
+ for code, requestID := range s.codes {
+ if id == requestID {
+ delete(s.codes, code)
+ return nil
+ }
+ }
+ return nil
+}
+
+//CreateAccessToken implements the op.Storage interface
+//it will be called for all requests able to return an access token (Authorization Code Flow, Implicit Flow, JWT Profile, ...)
+func (s *storage) CreateAccessToken(ctx context.Context, request op.TokenRequest) (string, time.Time, error) {
+ var applicationID string
+ //if authenticated for an app (auth code / implicit flow) we must save the client_id to the token
+ authReq, ok := request.(*AuthRequest)
+ if ok {
+ applicationID = authReq.ApplicationID
+ }
+ token, err := s.accessToken(applicationID, "", request.GetSubject(), request.GetAudience(), request.GetScopes())
+ if err != nil {
+ return "", time.Time{}, err
+ }
+ return token.ID, token.Expiration, nil
+}
+
+//CreateAccessAndRefreshTokens implements the op.Storage interface
+//it will be called for all requests able to return an access and refresh token (Authorization Code Flow, Refresh Token Request)
+func (s *storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) {
+ //get the information depending on the request type / implementation
+ applicationID, authTime, amr := getInfoFromRequest(request)
+
+ //if currentRefreshToken is empty (Code Flow) we will have to create a new refresh token
+ if currentRefreshToken == "" {
+ refreshTokenID := uuid.NewString()
+ accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes())
+ if err != nil {
+ return "", "", time.Time{}, err
+ }
+ refreshToken, err := s.createRefreshToken(accessToken, amr, authTime)
+ if err != nil {
+ return "", "", time.Time{}, err
+ }
+ return accessToken.ID, refreshToken, accessToken.Expiration, nil
+ }
+
+ //if we get here, the currentRefreshToken was not empty, so the call is a refresh token request
+ //we therefore will have to check the currentRefreshToken and renew the refresh token
+ refreshToken, refreshTokenID, err := s.renewRefreshToken(currentRefreshToken)
+ if err != nil {
+ return "", "", time.Time{}, err
+ }
+ accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes())
+ if err != nil {
+ return "", "", time.Time{}, err
+ }
+ return accessToken.ID, refreshToken, accessToken.Expiration, nil
+}
+
+//TokenRequestByRefreshToken implements the op.Storage interface
+//it will be called after parsing and validation of the refresh token request
+func (s *storage) TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (op.RefreshTokenRequest, error) {
+ token, ok := s.refreshTokens[refreshToken]
+ if !ok {
+ return nil, fmt.Errorf("invalid refresh_token")
+ }
+ return RefreshTokenRequestFromBusiness(token), nil
+}
+
+//TerminateSession implements the op.Storage interface
+//it will be called after the user signed out, therefore the access and refresh token of the user of this client must be removed
+func (s *storage) TerminateSession(ctx context.Context, userID string, clientID string) error {
+ for _, token := range s.tokens {
+ if token.ApplicationID == clientID && token.Subject == userID {
+ delete(s.tokens, token.ID)
+ delete(s.refreshTokens, token.RefreshTokenID)
+ return nil
+ }
+ }
+ return nil
+}
+
+//RevokeToken implements the op.Storage interface
+//it will be called after parsing and validation of the token revocation request
+func (s *storage) RevokeToken(ctx context.Context, token string, userID string, clientID string) *oidc.Error {
+ //a single token was requested to be removed
+ accessToken, ok := s.tokens[token]
+ if ok {
+ if accessToken.ApplicationID != clientID {
+ return oidc.ErrInvalidClient().WithDescription("token was not issued for this client")
+ }
+ //if it is an access token, just remove it
+ //you could also remove the corresponding refresh token if really necessary
+ delete(s.tokens, accessToken.ID)
+ return nil
+ }
+ refreshToken, ok := s.refreshTokens[token]
+ if !ok {
+ //if the token is neither an access nor a refresh token, just ignore it, the expected behaviour of
+ //being not valid (anymore) is achieved
+ return nil
+ }
+ if refreshToken.ApplicationID != clientID {
+ return oidc.ErrInvalidClient().WithDescription("token was not issued for this client")
+ }
+ //if it is a refresh token, you will have to remove the access token as well
+ delete(s.refreshTokens, refreshToken.ID)
+ for _, accessToken := range s.tokens {
+ if accessToken.RefreshTokenID == refreshToken.ID {
+ delete(s.tokens, accessToken.ID)
+ return nil
+ }
+ }
+ return nil
+}
+
+//GetSigningKey implements the op.Storage interface
+//it will be called when creating the OpenID Provider
+func (s *storage) GetSigningKey(ctx context.Context, keyCh chan<- jose.SigningKey) {
+ //in this example the signing key is a static rsa.PrivateKey and the algorithm used is RS256
+ //you would obviously have a more complex implementation and store / retrieve the key from your database as well
+ //
+ //the idea of the signing key channel is, that you can (with what ever mechanism) rotate your signing key and
+ //switch the key of the signer via this channel
+ keyCh <- jose.SigningKey{
+ Algorithm: jose.SignatureAlgorithm(s.signingKey.Algorithm), //always tell the signer with algorithm to use
+ Key: jose.JSONWebKey{
+ KeyID: s.signingKey.ID, //always give the key an id so, that it will include it in the token header as `kid` claim
+ Key: s.signingKey.Key,
+ },
+ }
+}
+
+//GetKeySet implements the op.Storage interface
+//it will be called to get the current (public) keys, among others for the keys_endpoint or for validating access_tokens on the userinfo_endpoint, ...
+func (s *storage) GetKeySet(ctx context.Context) (*jose.JSONWebKeySet, error) {
+ //as mentioned above, this example only has a single signing key without key rotation,
+ //so it will directly use its public key
+ //
+ //when using key rotation you typically would store the public keys alongside the private keys in your database
+ //and give both of them an expiration date, with the public key having a longer lifetime (e.g. rotate private key every
+ return &jose.JSONWebKeySet{Keys: []jose.JSONWebKey{
+ {
+ KeyID: s.signingKey.ID,
+ Algorithm: s.signingKey.Algorithm,
+ Use: oidc.KeyUseSignature,
+ Key: &s.signingKey.Key.PublicKey,
+ }},
+ }, nil
+}
+
+//GetClientByClientID implements the op.Storage interface
+//it will be called whenever information (type, redirect_uris, ...) about the client behind the client_id is needed
+func (s *storage) GetClientByClientID(ctx context.Context, clientID string) (op.Client, error) {
+ client, ok := s.clients[clientID]
+ if !ok {
+ return nil, fmt.Errorf("client not found")
+ }
+ return client, nil
+}
+
+//AuthorizeClientIDSecret implements the op.Storage interface
+//it will be called for validating the client_id, client_secret on token or introspection requests
+func (s *storage) AuthorizeClientIDSecret(ctx context.Context, clientID, clientSecret string) error {
+ client, ok := s.clients[clientID]
+ if !ok {
+ return fmt.Errorf("client not found")
+ }
+ //for this example we directly check the secret
+ //obviously you would not have the secret in plain text, but rather hashed and salted (e.g. using bcrypt)
+ if client.secret != clientSecret {
+ return fmt.Errorf("invalid secret")
+ }
+ return nil
+}
+
+//SetUserinfoFromScopes implements the op.Storage interface
+//it will be called for the creation of an id_token, so we'll just pass it to the private function without any further check
+func (s *storage) SetUserinfoFromScopes(ctx context.Context, userinfo oidc.UserInfoSetter, userID, clientID string, scopes []string) error {
+ return s.setUserinfo(ctx, userinfo, userID, clientID, scopes)
+}
+
+//SetUserinfoFromToken implements the op.Storage interface
+//it will be called for the userinfo endpoint, so we read the token and pass the information from that to the private function
+func (s *storage) SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserInfoSetter, tokenID, subject, origin string) error {
+ token, ok := s.tokens[tokenID]
+ if !ok {
+ return fmt.Errorf("token is invalid or has expired")
+ }
+ //the userinfo endpoint should support CORS. If it's not possible to specify a specific origin in the CORS handler,
+ //and you have to specify a wildcard (*) origin, then you could also check here if the origin which called the userinfo endpoint here directly
+ //note that the origin can be empty (if called by a web client)
+ //
+ //if origin != "" {
+ // client, ok := s.clients[token.ApplicationID]
+ // if !ok {
+ // return fmt.Errorf("client not found")
+ // }
+ // if err := checkAllowedOrigins(client.allowedOrigins, origin); err != nil {
+ // return err
+ // }
+ //}
+ return s.setUserinfo(ctx, userinfo, token.Subject, token.ApplicationID, token.Scopes)
+}
+
+//SetIntrospectionFromToken implements the op.Storage interface
+//it will be called for the introspection endpoint, so we read the token and pass the information from that to the private function
+func (s *storage) SetIntrospectionFromToken(ctx context.Context, introspection oidc.IntrospectionResponse, tokenID, subject, clientID string) error {
+ token, ok := s.tokens[tokenID]
+ if !ok {
+ return fmt.Errorf("token is invalid or has expired")
+ }
+ //check if the client is part of the requested audience
+ for _, aud := range token.Audience {
+ if aud == clientID {
+ //the introspection response only has to return a boolean (active) if the token is active
+ //this will automatically be done by the library if you don't return an error
+ //you can also return further information about the user / associated token
+ //e.g. the userinfo (equivalent to userinfo endpoint)
+ err := s.setUserinfo(ctx, introspection, subject, clientID, token.Scopes)
+ if err != nil {
+ return err
+ }
+ //...and also the requested scopes...
+ introspection.SetScopes(token.Scopes)
+ //...and the client the token was issued to
+ introspection.SetClientID(token.ApplicationID)
+ return nil
+ }
+ }
+ return fmt.Errorf("token is not valid for this client")
+}
+
+//GetPrivateClaimsFromScopes implements the op.Storage interface
+//it will be called for the creation of a JWT access token to assert claims for custom scopes
+func (s *storage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) {
+ for _, scope := range scopes {
+ switch scope {
+ case CustomScope:
+ claims = appendClaim(claims, CustomClaim, customClaim(clientID))
+ }
+ }
+ return claims, nil
+}
+
+//GetKeyByIDAndUserID implements the op.Storage interface
+//it will be called to validate the signatures of a JWT (JWT Profile Grant and Authentication)
+func (s *storage) GetKeyByIDAndUserID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) {
+ service, ok := s.services[userID]
+ if !ok {
+ return nil, fmt.Errorf("user not found")
+ }
+ key, ok := service.keys[keyID]
+ if !ok {
+ return nil, fmt.Errorf("key not found")
+ }
+ return &jose.JSONWebKey{
+ KeyID: keyID,
+ Use: "sig",
+ Key: key,
+ }, nil
+}
+
+//ValidateJWTProfileScopes implements the op.Storage interface
+//it will be called to validate the scopes of a JWT Profile Authorization Grant request
+func (s *storage) ValidateJWTProfileScopes(ctx context.Context, userID string, scopes []string) ([]string, error) {
+ allowedScopes := make([]string, 0)
+ for _, scope := range scopes {
+ if scope == oidc.ScopeOpenID {
+ allowedScopes = append(allowedScopes, scope)
+ }
+ }
+ return allowedScopes, nil
+}
+
+//Health implements the op.Storage interface
+func (s *storage) Health(ctx context.Context) error {
+ return nil
+}
+
+//createRefreshToken will store a refresh_token in-memory based on the provided information
+func (s *storage) createRefreshToken(accessToken *Token, amr []string, authTime time.Time) (string, error) {
+ token := &RefreshToken{
+ ID: accessToken.RefreshTokenID,
+ Token: accessToken.RefreshTokenID,
+ AuthTime: authTime,
+ AMR: amr,
+ ApplicationID: accessToken.ApplicationID,
+ UserID: accessToken.Subject,
+ Audience: accessToken.Audience,
+ Expiration: time.Now().Add(5 * time.Hour),
+ Scopes: accessToken.Scopes,
+ }
+ s.refreshTokens[token.ID] = token
+ return token.Token, nil
+}
+
+//renewRefreshToken checks the provided refresh_token and creates a new one based on the current
+func (s *storage) renewRefreshToken(currentRefreshToken string) (string, string, error) {
+ refreshToken, ok := s.refreshTokens[currentRefreshToken]
+ if !ok {
+ return "", "", fmt.Errorf("invalid refresh token")
+ }
+ //deletes the refresh token and all access tokens which were issued based on this refresh token
+ delete(s.refreshTokens, currentRefreshToken)
+ for _, token := range s.tokens {
+ if token.RefreshTokenID == currentRefreshToken {
+ delete(s.tokens, token.ID)
+ break
+ }
+ }
+ //creates a new refresh token based on the current one
+ token := uuid.NewString()
+ refreshToken.Token = token
+ s.refreshTokens[token] = refreshToken
+ return token, refreshToken.ID, nil
+}
+
+//accessToken will store an access_token in-memory based on the provided information
+func (s *storage) accessToken(applicationID, refreshTokenID, subject string, audience, scopes []string) (*Token, error) {
+ token := &Token{
+ ID: uuid.NewString(),
+ ApplicationID: applicationID,
+ RefreshTokenID: refreshTokenID,
+ Subject: subject,
+ Audience: audience,
+ Expiration: time.Now().Add(5 * time.Minute),
+ Scopes: scopes,
+ }
+ s.tokens[token.ID] = token
+ return token, nil
+}
+
+//setUserinfo sets the info based on the user, scopes and if necessary the clientID
+func (s *storage) setUserinfo(ctx context.Context, userInfo oidc.UserInfoSetter, userID, clientID string, scopes []string) (err error) {
+ user, ok := s.users[userID]
+ if !ok {
+ return fmt.Errorf("user not found")
+ }
+ for _, scope := range scopes {
+ switch scope {
+ case oidc.ScopeOpenID:
+ userInfo.SetSubject(user.id)
+ case oidc.ScopeEmail:
+ userInfo.SetEmail(user.email, user.emailVerified)
+ case oidc.ScopeProfile:
+ userInfo.SetPreferredUsername(user.username)
+ userInfo.SetName(user.firstname + " " + user.lastname)
+ userInfo.SetFamilyName(user.lastname)
+ userInfo.SetGivenName(user.firstname)
+ userInfo.SetLocale(user.preferredLanguage)
+ case oidc.ScopePhone:
+ userInfo.SetPhone(user.phone, user.phoneVerified)
+ case CustomScope:
+ //you can also have a custom scope and assert public or custom claims based on that
+ userInfo.AppendClaims(CustomClaim, customClaim(clientID))
+ }
+ }
+ return nil
+}
+
+//getInfoFromRequest returns the clientID, authTime and amr depending on the op.TokenRequest type / implementation
+func getInfoFromRequest(req op.TokenRequest) (clientID string, authTime time.Time, amr []string) {
+ authReq, ok := req.(*AuthRequest) //Code Flow (with scope offline_access)
+ if ok {
+ return authReq.ApplicationID, authReq.authTime, authReq.GetAMR()
+ }
+ refreshReq, ok := req.(*RefreshTokenRequest) //Refresh Token Request
+ if ok {
+ return refreshReq.ApplicationID, refreshReq.AuthTime, refreshReq.AMR
+ }
+ return "", time.Time{}, nil
+}
+
+//customClaim demonstrates how to return custom claims based on provided information
+func customClaim(clientID string) map[string]interface{} {
+ return map[string]interface{}{
+ "client": clientID,
+ "other": "stuff",
+ }
+}
+
+func appendClaim(claims map[string]interface{}, claim string, value interface{}) map[string]interface{} {
+ if claims == nil {
+ claims = make(map[string]interface{})
+ }
+ claims[claim] = value
+ return claims
+}
diff --git a/example/server/internal/token.go b/example/server/internal/token.go
new file mode 100644
index 0000000..09e675a
--- /dev/null
+++ b/example/server/internal/token.go
@@ -0,0 +1,25 @@
+package internal
+
+import "time"
+
+type Token struct {
+ ID string
+ ApplicationID string
+ Subject string
+ RefreshTokenID string
+ Audience []string
+ Expiration time.Time
+ Scopes []string
+}
+
+type RefreshToken struct {
+ ID string
+ Token string
+ AuthTime time.Time
+ AMR []string
+ Audience []string
+ UserID string
+ ApplicationID string
+ Expiration time.Time
+ Scopes []string
+}
diff --git a/example/server/internal/user.go b/example/server/internal/user.go
new file mode 100644
index 0000000..19b5d1f
--- /dev/null
+++ b/example/server/internal/user.go
@@ -0,0 +1,24 @@
+package internal
+
+import (
+ "crypto/rsa"
+
+ "golang.org/x/text/language"
+)
+
+type User struct {
+ id string
+ username string
+ password string
+ firstname string
+ lastname string
+ email string
+ emailVerified bool
+ phone string
+ phoneVerified bool
+ preferredLanguage language.Tag
+}
+
+type Service struct {
+ keys map[string]*rsa.PublicKey
+}
diff --git a/example/server/login.go b/example/server/login.go
new file mode 100644
index 0000000..90d01d8
--- /dev/null
+++ b/example/server/login.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+ "fmt"
+ "html/template"
+ "net/http"
+
+ "github.com/gorilla/mux"
+)
+
+const (
+ queryAuthRequestID = "authRequestID"
+)
+
+var (
+ loginTmpl, _ = template.New("login").Parse(`
+
+
+
+
+ Login
+
+
+
+
+ `)
+)
+
+type login struct {
+ authenticate authenticate
+ router *mux.Router
+ callback func(string) string
+}
+
+func NewLogin(authenticate authenticate, callback func(string) string) *login {
+ l := &login{
+ authenticate: authenticate,
+ callback: callback,
+ }
+ l.createRouter()
+ return l
+}
+
+func (l *login) createRouter() {
+ l.router = mux.NewRouter()
+ l.router.Path("/username").Methods("GET").HandlerFunc(l.loginHandler)
+ l.router.Path("/username").Methods("POST").HandlerFunc(l.checkLoginHandler)
+}
+
+type authenticate interface {
+ CheckUsernamePassword(username, password, id string) error
+}
+
+func (l *login) loginHandler(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError)
+ return
+ }
+ //the oidc package will pass the id of the auth request as query parameter
+ //we will use this id through the login process and therefore pass it to the login page
+ renderLogin(w, r.FormValue(queryAuthRequestID), nil)
+}
+
+func renderLogin(w http.ResponseWriter, id string, err error) {
+ var errMsg string
+ if err != nil {
+ errMsg = err.Error()
+ }
+ data := &struct {
+ ID string
+ Error string
+ }{
+ ID: id,
+ Error: errMsg,
+ }
+ err = loginTmpl.Execute(w, data)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func (l *login) checkLoginHandler(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError)
+ return
+ }
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+ id := r.FormValue("id")
+ err = l.authenticate.CheckUsernamePassword(username, password, id)
+ if err != nil {
+ renderLogin(w, id, err)
+ return
+ }
+ http.Redirect(w, r, l.callback(id), http.StatusFound)
+}
diff --git a/example/server/op.go b/example/server/op.go
new file mode 100644
index 0000000..54b5041
--- /dev/null
+++ b/example/server/op.go
@@ -0,0 +1,126 @@
+package main
+
+import (
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+
+ "github.com/gorilla/mux"
+ "golang.org/x/text/language"
+
+ "github.com/caos/oidc/example/server/internal"
+ "github.com/caos/oidc/pkg/op"
+)
+
+const (
+ pathLoggedOut = "/logged-out"
+)
+
+func init() {
+ internal.RegisterClients(
+ internal.NativeClient("native"),
+ internal.WebClient("web", "secret"),
+ internal.WebClient("api", "secret"),
+ )
+}
+
+func main() {
+ ctx := context.Background()
+
+ //this will allow us to use an issuer with http:// instead of https://
+ os.Setenv(op.OidcDevMode, "true")
+
+ port := "9998"
+
+ //the OpenID Provider requires a 32-byte key for (token) encryption
+ //be sure to create a proper crypto random key and manage it securely!
+ key := sha256.Sum256([]byte("test"))
+
+ router := mux.NewRouter()
+
+ //for simplicity, we provide a very small default page for users who have signed out
+ router.HandleFunc(pathLoggedOut, func(w http.ResponseWriter, req *http.Request) {
+ _, err := w.Write([]byte("signed out successfully"))
+ if err != nil {
+ log.Printf("error serving logged out page: %v", err)
+ }
+ })
+
+ //the OpenIDProvider interface needs a Storage interface handling various checks and state manipulations
+ //this might be the layer for accessing your database
+ //in this example it will be handled in-memory
+ storage := internal.NewStorage()
+
+ //creation of the OpenIDProvider with the just created in-memory Storage
+ provider, err := newOP(ctx, storage, port, key)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ //the provider will only take care of the OpenID Protocol, so there must be some sort of UI for the login process
+ //for the simplicity of the example this means a simple page with username and password field
+ l := NewLogin(storage, op.AuthCallbackURL(provider))
+
+ //regardless of how many pages / steps there are in the process, the UI must be registered in the router,
+ //so we will direct all calls to /login to the login UI
+ router.PathPrefix("/login/").Handler(http.StripPrefix("/login", l.router))
+
+ //we register the http handler of the OP on the root, so that the discovery endpoint (/.well-known/openid-configuration)
+ //is served on the correct path
+ //
+ //if your issuer ends with a path (e.g. http://localhost:9998/custom/path/),
+ //then you would have to set the path prefix (/custom/path/)
+ router.PathPrefix("/").Handler(provider.HttpHandler())
+
+ server := &http.Server{
+ Addr: ":" + port,
+ Handler: router,
+ }
+ err = server.ListenAndServe()
+ if err != nil {
+ log.Fatal(err)
+ }
+ <-ctx.Done()
+}
+
+//newOP will create an OpenID Provider for localhost on a specified port with a given encryption key
+//and a predefined default logout uri
+//it will enable all options (see descriptions)
+func newOP(ctx context.Context, storage op.Storage, port string, key [32]byte) (op.OpenIDProvider, error) {
+ config := &op.Config{
+ Issuer: fmt.Sprintf("http://localhost:%s/", port),
+ CryptoKey: key,
+
+ //will be used if the end_session endpoint is called without a post_logout_redirect_uri
+ DefaultLogoutRedirectURI: pathLoggedOut,
+
+ //enables code_challenge_method S256 for PKCE (and therefore PKCE in general)
+ CodeMethodS256: true,
+
+ //enables additional client_id/client_secret authentication by form post (not only HTTP Basic Auth)
+ AuthMethodPost: true,
+
+ //enables additional authentication by using private_key_jwt
+ AuthMethodPrivateKeyJWT: true,
+
+ //enables refresh_token grant use
+ GrantTypeRefreshToken: true,
+
+ //enables use of the `request` Object parameter
+ RequestObjectSupported: true,
+
+ //this example has only static texts (in English), so we'll set the here accordingly
+ SupportedUILocales: []language.Tag{language.English},
+ }
+ handler, err := op.NewOpenIDProvider(ctx, config, storage,
+ //as an example on how to customize an endpoint this will change the authorization_endpoint from /authorize to /auth
+ op.WithCustomAuthEndpoint(op.NewEndpoint("auth")),
+ )
+ if err != nil {
+ return nil, err
+ }
+ return handler, nil
+}
diff --git a/example/server/service-key1.json b/example/server/service-key1.json
new file mode 100644
index 0000000..a0d20e8
--- /dev/null
+++ b/example/server/service-key1.json
@@ -0,0 +1 @@
+{"type":"serviceaccount","keyId":"key1","key":"-----BEGIN RSA PRIVATE KEY-----\nMIICXgIBAAKBgQD21E+180rCAzp15zy2X/JOYYHtxYhF51pWCsITeChJd7sFWxp1\ntxSHTiomQYBiBWgcCavsdu/VLPQJhO3PTIyglxc1XRGsM48oDT5MkFsAVDvbjuWk\nF0lstQyw4pr8Wg0Ecf1aL6YlvVKB9h5rAgZ9T+elNJ7q5takMAvNhu7zMQIDAQAB\nAoGAeLRw2qjEaUZM43WWchVPmFcEw/MyZgTyX1tZd03uXacolUDtGp3ScyydXiHw\nF39PX063fabYOCaInNMdvJ9RsQz2OcZuS/K6NOmWhzBfLgs4Y1tU6ijoY/gBjHgu\nCV0KjvoWIfEtKl/On/wTrAnUStFzrc7U4dpKFP1fy2ZTTnECQQD8aP2QOxmKUyfg\nBAjfonpkrNeaTRNwTULTvEHFiLyaeFd1PAvsDiKZtpk6iHLb99mQZkVVtAK5qgQ4\n1OI72jkVAkEA+lcAamuZAM+gIiUhbHA7BfX9OVgyGDD2tx5g/kxhMUmK6hIiO6Ul\n0nw5KfrCEUU3AzrM7HejUg3q61SYcXTgrQJBALhrzbhwNf0HPP9Ec2dSw7KDRxSK\ndEV9bfJefn/hpEwI2X3i3aMfwNAmxlYqFCH8OY5z6vzvhX46ZtNPV+z7SPECQQDq\nApXi5P27YlpgULEzup2R7uZsymLZdjvJ5V3pmOBpwENYlublNnVqkrCk60CqADdy\nj26rxRIoS9ZDcWqm9AhpAkEAyrNXBMJh08ghBMb3NYPFfr/bftRJSrGjhBPuJ5qr\nXzWaXhYVMMh3OSAwzHBJbA1ffdQJuH2ebL99Ur5fpBcbVw==\n-----END RSA PRIVATE KEY-----\n","userId":"service"}