diff --git a/README.md b/README.md index cbf680e..d773357 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. @@ -35,10 +54,10 @@ CLIENT_ID=web CLIENT_SECRET=web ISSUER=http://localhost:9998/ SCOPES=openid PORT ## 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/server/internal/client.go b/example/server/internal/client.go new file mode 100644 index 0000000..a5bb0e6 --- /dev/null +++ b/example/server/internal/client.go @@ -0,0 +1,152 @@ +package internal + +import ( + "time" + + "github.com/caos/oidc/pkg/oidc" + "github.com/caos/oidc/pkg/op" +) + +var clients = map[string]*Client{} + +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, + defaultLoginURL: 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 PKCE and allow the use of refresh tokens +//user-defined redirectURIs may include: +// - http://localhost without port specification (e.g. http://localhost/auth/callback) +//(the example will be used as default, if none is provided) +func WebClient(id, secret string, redirectURIs ...string) *Client { + return &Client{ + id: id, + secret: secret, + redirectURIs: []string{ + "http://localhost:9999/auth/callback", + }, + applicationType: op.ApplicationTypeWeb, + authMethod: oidc.AuthMethodBasic, + defaultLoginURL: defaultLoginURL, + responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode}, + grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken}, + accessTokenType: 0, + devMode: false, + idTokenUserinfoClaimsAssertion: false, + clockSkew: 0, + } +} + +type Client struct { + id string + secret string + redirectURIs []string + applicationType op.ApplicationType + authMethod oidc.AuthMethod + defaultLoginURL func(string) string + responseTypes []oidc.ResponseType + grantTypes []oidc.GrantType + accessTokenType op.AccessTokenType + devMode bool + idTokenUserinfoClaimsAssertion bool + clockSkew time.Duration +} + +func (c *Client) GetID() string { + return c.id +} + +func (c *Client) RedirectURIs() []string { + return c.redirectURIs +} + +func (c *Client) PostLogoutRedirectURIs() []string { + return []string{} +} + +func (c *Client) ApplicationType() op.ApplicationType { + return c.applicationType +} + +func (c *Client) AuthMethod() oidc.AuthMethod { + return c.authMethod +} + +func (c *Client) ResponseTypes() []oidc.ResponseType { + return c.responseTypes +} + +func (c *Client) GrantTypes() []oidc.GrantType { + return c.grantTypes +} + +func (c *Client) LoginURL(id string) string { + //we use the default login UI and pass the (auth request) id, + //but you could implement some logic here to redirect the users to different login UIs depending on the client + return c.defaultLoginURL(id) +} + +func (c *Client) AccessTokenType() op.AccessTokenType { + return c.accessTokenType +} + +func (c *Client) IDTokenLifetime() time.Duration { + return 1 * time.Hour +} + +func (c *Client) DevMode() bool { + return c.devMode +} + +func (c *Client) RestrictAdditionalIdTokenScopes() func(scopes []string) []string { + return func(scopes []string) []string { + return scopes + } +} + +func (c *Client) RestrictAdditionalAccessTokenScopes() func(scopes []string) []string { + return func(scopes []string) []string { + return scopes + } +} + +func (c *Client) IsScopeAllowed(scope string) bool { + return false +} + +func (c *Client) IDTokenUserinfoClaimsAssertion() bool { + return c.idTokenUserinfoClaimsAssertion +} + +func (c *Client) ClockSkew() time.Duration { + return c.clockSkew +} diff --git a/example/server/internal/oidc.go b/example/server/internal/oidc.go new file mode 100644 index 0000000..e514d85 --- /dev/null +++ b/example/server/internal/oidc.go @@ -0,0 +1,257 @@ +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 []Prompt + UiLocales []language.Tag + LoginHint string + MaxAuthAge *time.Duration + UserID string + Scopes []string + ResponseType OIDCResponseType + 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 ResponseTypeToOIDC(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 +} + +type Prompt int32 + +const ( + PromptUnspecified Prompt = iota + PromptNone + PromptLogin + PromptConsent + PromptSelectAccount +) + +func PromptToInternal(oidcPrompt oidc.SpaceDelimitedArray) []Prompt { + prompts := make([]Prompt, len(oidcPrompt)) + for _, oidcPrompt := range oidcPrompt { + switch oidcPrompt { + case oidc.PromptNone: + prompts = append(prompts, PromptNone) + case oidc.PromptLogin: + prompts = append(prompts, PromptLogin) + case oidc.PromptConsent: + prompts = append(prompts, PromptConsent) + case oidc.PromptSelectAccount: + prompts = append(prompts, PromptSelectAccount) + } + } + return prompts +} + +type OIDCResponseType int32 + +const ( + OIDCResponseTypeCode OIDCResponseType = iota + OIDCResponseTypeIDToken + OIDCResponseTypeIDTokenToken +) + +func ResponseTypeToInternal(responseType oidc.ResponseType) OIDCResponseType { + switch responseType { + case oidc.ResponseTypeCode: + return OIDCResponseTypeCode + case oidc.ResponseTypeIDTokenOnly: + return OIDCResponseTypeIDToken + case oidc.ResponseTypeIDToken: + return OIDCResponseTypeIDTokenToken + default: + return OIDCResponseTypeCode + } +} + +func MaxAgeToInternal(maxAge *uint) *time.Duration { + if maxAge == nil { + return nil + } + dur := time.Duration(*maxAge) * time.Second + return &dur +} + +type AuthRequestOIDC struct { + Scopes []string + ResponseType interface{} + Nonce string + CodeChallenge *OIDCCodeChallenge +} + +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: ResponseTypeToInternal(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, + } +} + +func ResponseTypeToOIDC(responseType OIDCResponseType) oidc.ResponseType { + switch responseType { + case OIDCResponseTypeCode: + return oidc.ResponseTypeCode + case OIDCResponseTypeIDTokenToken: + return oidc.ResponseTypeIDToken + case OIDCResponseTypeIDToken: + return oidc.ResponseTypeIDTokenOnly + default: + return oidc.ResponseTypeCode + } +} + +//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..a50d256 --- /dev/null +++ b/example/server/internal/storage.go @@ -0,0 +1,503 @@ +package internal + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "fmt" + "time" + + "github.com/google/uuid" + "gopkg.in/square/go-jose.v2" + + "github.com/caos/oidc/pkg/oidc" + "github.com/caos/oidc/pkg/op" +) + +//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 + refreshTokens map[string]*RefreshToken + signingKey signingKey +} + +type signingKey struct { + ID string + Algorithm string + Key *rsa.PrivateKey +} + +//TODO: describe +var defaultLoginURL = func(id string) string { + return "/login/username?authRequestID=" + id +} + +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, + 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 { + //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) + user, ok := s.users[username] + if !ok || user.password != password { + return fmt.Errorf("username or password wrong") + } + request, ok := s.authRequests[id] + if !ok { + return fmt.Errorf("request not found") + } + + //be sure to set user id into the auth request after the user was checked (either with or without password), + //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 +} + +//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) { + return nil, fmt.Errorf("example does not yet support authentication with JWT") //TODO: implement +} + +//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) { + return nil, fmt.Errorf("example does not yet support authentication with JWT") //TODO: implement +} + +//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 +} + +//createRefreshToken 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..8d33f0f --- /dev/null +++ b/example/server/internal/user.go @@ -0,0 +1,16 @@ +package internal + +import "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 +} 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..956e9a3 --- /dev/null +++ b/example/server/op.go @@ -0,0 +1,121 @@ +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() + + os.Setenv(op.OidcDevMode, "true") + port := "9998" + 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: false, //TODO: implement and set to 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 +}