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 + + +
+ + + +
+ + +
+ +
+ + +
+ +

{{.Error}}

+ + +
+ + `) +) + +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"}