chore: Make example/server usable for tests (#205)

* internal -> storage; split users into an interface

* move example/server/*.go to example/server/exampleop/

* export all User fields

* storage -> Storage

* example server now passes tests
This commit is contained in:
David Sharnoff 2022-09-29 22:44:10 -07:00 committed by GitHub
parent 62daf4cc42
commit 749c30491b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 860 additions and 753 deletions

View file

@ -1,4 +1,4 @@
package main
package exampleop
import (
"fmt"
@ -12,8 +12,7 @@ const (
queryAuthRequestID = "authRequestID"
)
var (
loginTmpl, _ = template.New("login").Parse(`
var loginTmpl, _ = template.New("login").Parse(`
<!DOCTYPE html>
<html>
<head>
@ -41,7 +40,6 @@ var (
</form>
</body>
</html>`)
)
type login struct {
authenticate authenticate
@ -74,8 +72,8 @@ func (l *login) loginHandler(w http.ResponseWriter, r *http.Request) {
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
// 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)
}

View file

@ -0,0 +1,116 @@
package exampleop
import (
"context"
"crypto/sha256"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
"golang.org/x/text/language"
"github.com/zitadel/oidc/example/server/storage"
"github.com/zitadel/oidc/pkg/op"
)
const (
pathLoggedOut = "/logged-out"
)
func init() {
storage.RegisterClients(
storage.NativeClient("native"),
storage.WebClient("web", "secret"),
storage.WebClient("api", "secret"),
)
}
type Storage interface {
op.Storage
CheckUsernamePassword(username, password, id string) error
}
// SetupServer creates an OIDC server with Issuer=http://localhost:<port>
//
// Use one of the pre-made clients in storage/clients.go or register a new one.
func SetupServer(ctx context.Context, issuer string, storage Storage) *mux.Router {
// this will allow us to use an issuer with http:// instead of https://
os.Setenv(op.OidcDevMode, "true")
// 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)
}
})
// creation of the OpenIDProvider with the just created in-memory Storage
provider, err := newOP(ctx, storage, issuer, 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())
return router
}
// 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, issuer string, key [32]byte) (op.OpenIDProvider, error) {
config := &op.Config{
Issuer: issuer,
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
}

View file

@ -1,553 +0,0 @@
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/zitadel/oidc/pkg/oidc"
"github.com/zitadel/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
}

View file

@ -1,24 +0,0 @@
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
}

32
example/server/main.go Normal file
View file

@ -0,0 +1,32 @@
package main
import (
"context"
"log"
"net/http"
"github.com/zitadel/oidc/example/server/exampleop"
"github.com/zitadel/oidc/example/server/storage"
)
func main() {
ctx := context.Background()
// 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 := storage.NewStorage(storage.NewUserStore())
port := "9998"
router := exampleop.SetupServer(ctx, "http://localhost:"+port, storage)
server := &http.Server{
Addr: ":" + port,
Handler: router,
}
err := server.ListenAndServe()
if err != nil {
log.Fatal(err)
}
<-ctx.Done()
}

View file

@ -1,126 +0,0 @@
package main
import (
"context"
"crypto/sha256"
"fmt"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
"golang.org/x/text/language"
"github.com/zitadel/oidc/example/server/internal"
"github.com/zitadel/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
}

View file

@ -1,4 +1,4 @@
package internal
package storage
import (
"time"
@ -8,17 +8,17 @@ import (
)
var (
//we use the default login UI and pass the (auth request) id
// 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 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
// Client represents the storage model of an OAuth/OIDC client
// this could also be your database model
type Client struct {
id string
secret string
@ -34,108 +34,111 @@ type Client struct {
clockSkew time.Duration
}
//GetID must return the client_id
// 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
// 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
// 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)
// 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)
// 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
// 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)
// 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
// 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)
// 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
// 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)
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
//
// RegisterClients should be called before the Storage is used so that there are
// no race conditions.
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:
// 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)
@ -148,7 +151,7 @@ func NativeClient(id string, redirectURIs ...string) *Client {
}
return &Client{
id: id,
secret: "", //no secret needed (due to PKCE)
secret: "", // no secret needed (due to PKCE)
redirectURIs: redirectURIs,
applicationType: op.ApplicationTypeNative,
authMethod: oidc.AuthMethodNone,
@ -162,8 +165,8 @@ func NativeClient(id string, redirectURIs ...string) *Client {
}
}
//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:
// 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 {

View file

@ -1,4 +1,4 @@
package internal
package storage
import (
"time"
@ -11,11 +11,11 @@ import (
)
const (
//CustomScope is an example for how to use custom scopes in this library
// 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 is an example for how to return custom claims with this library
CustomClaim = "custom_claim"
)
@ -44,11 +44,11 @@ func (a *AuthRequest) GetID() string {
}
func (a *AuthRequest) GetACR() string {
return "" //we won't handle acr in this example
return "" // we won't handle acr in this example
}
func (a *AuthRequest) GetAMR() []string {
//this example only uses password for authentication
// this example only uses password for authentication
if a.passwordChecked {
return []string{"pwd"}
}
@ -56,7 +56,7 @@ func (a *AuthRequest) GetAMR() []string {
}
func (a *AuthRequest) GetAudience() []string {
return []string{a.ApplicationID} //this example will always just use the client_id as audience
return []string{a.ApplicationID} // this example will always just use the client_id as audience
}
func (a *AuthRequest) GetAuthTime() time.Time {
@ -84,7 +84,7 @@ func (a *AuthRequest) GetResponseType() oidc.ResponseType {
}
func (a *AuthRequest) GetResponseMode() oidc.ResponseMode {
return "" //we won't handle response mode in this example
return "" // we won't handle response mode in this example
}
func (a *AuthRequest) GetScopes() []string {
@ -100,7 +100,7 @@ func (a *AuthRequest) GetSubject() string {
}
func (a *AuthRequest) Done() bool {
return a.passwordChecked //this example only uses password for authentication
return a.passwordChecked // this example only uses password for authentication
}
func PromptToInternal(oidcPrompt oidc.SpaceDelimitedArray) []string {
@ -165,7 +165,7 @@ func CodeChallengeToOIDC(challenge *OIDCCodeChallenge) *oidc.CodeChallenge {
}
}
//RefreshTokenRequestFromBusiness will simply wrap the internal RefreshToken to implement the op.RefreshTokenRequest interface
// RefreshTokenRequestFromBusiness will simply wrap the storage RefreshToken to implement the op.RefreshTokenRequest interface
func RefreshTokenRequestFromBusiness(token *RefreshToken) op.RefreshTokenRequest {
return &RefreshTokenRequest{token}
}

View file

@ -0,0 +1,590 @@
package storage
import (
"context"
"crypto/rand"
"crypto/rsa"
"fmt"
"math/big"
"sync"
"time"
"github.com/google/uuid"
"gopkg.in/square/go-jose.v2"
"github.com/zitadel/oidc/pkg/oidc"
"github.com/zitadel/oidc/pkg/op"
)
// 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)
var serviceKey1 = &rsa.PublicKey{
N: func() *big.Int {
n, _ := new(big.Int).SetString("00f6d44fb5f34ac2033a75e73cb65ff24e6181edc58845e75a560ac21378284977bb055b1a75b714874e2a2641806205681c09abec76efd52cf40984edcf4c8ca09717355d11ac338f280d3e4c905b00543bdb8ee5a417496cb50cb0e29afc5a0d0471fd5a2fa625bd5281f61e6b02067d4fe7a5349eeae6d6a4300bcd86eef331", 16)
return n
}(),
E: 65537,
}
// var _ op.Storage = &storage{}
// var _ op.ClientCredentialsStorage = &storage{}
// 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 {
lock sync.Mutex
authRequests map[string]*AuthRequest
codes map[string]string
tokens map[string]*Token
clients map[string]*Client
userStore UserStore
services map[string]Service
refreshTokens map[string]*RefreshToken
signingKey signingKey
}
type signingKey struct {
ID string
Algorithm string
Key *rsa.PrivateKey
}
func NewStorage(userStore UserStore) *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,
userStore: userStore,
services: map[string]Service{
userStore.ExampleClientID(): {
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 {
s.lock.Lock()
defer s.lock.Unlock()
request, ok := s.authRequests[id]
if !ok {
return fmt.Errorf("request not found")
}
// for demonstration purposes we'll check we'll have a simple user store and
// a plain text password. For real world scenarios, be sure to have the password
// hashed and salted (e.g. using bcrypt)
user := s.userStore.GetUserByUsername(username)
if user != nil && 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) {
s.lock.Lock()
defer s.lock.Unlock()
// typically, you'll fill your storage / 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) {
s.lock.Lock()
defer s.lock.Unlock()
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 := func() (string, bool) {
s.lock.Lock()
defer s.lock.Unlock()
requestID, ok := s.codes[code]
return requestID, ok
}()
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.lock.Lock()
defer s.lock.Unlock()
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
s.lock.Lock()
defer s.lock.Unlock()
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) {
s.lock.Lock()
defer s.lock.Unlock()
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 {
s.lock.Lock()
defer s.lock.Unlock()
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
s.lock.Lock()
defer s.lock.Unlock()
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) {
s.lock.Lock()
defer s.lock.Unlock()
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 {
s.lock.Lock()
defer s.lock.Unlock()
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 := func() (*Token, bool) {
s.lock.Lock()
defer s.lock.Unlock()
token, ok := s.tokens[tokenID]
return token, ok
}()
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 := func() (*Token, bool) {
s.lock.Lock()
defer s.lock.Unlock()
token, ok := s.tokens[tokenID]
return token, ok
}()
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, clientID string) (*jose.JSONWebKey, error) {
s.lock.Lock()
defer s.lock.Unlock()
service, ok := s.services[clientID]
if !ok {
return nil, fmt.Errorf("clientID 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) {
s.lock.Lock()
defer s.lock.Unlock()
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) {
s.lock.Lock()
defer s.lock.Unlock()
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) {
s.lock.Lock()
defer s.lock.Unlock()
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) {
s.lock.Lock()
defer s.lock.Unlock()
user := s.userStore.GetUserByID(userID)
if user == nil {
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
}

View file

@ -1,4 +1,4 @@
package internal
package storage
import "time"

View file

@ -0,0 +1,71 @@
package storage
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
}
type UserStore interface {
GetUserByID(string) *User
GetUserByUsername(string) *User
ExampleClientID() string
}
type userStore struct {
users map[string]*User
}
func NewUserStore() UserStore {
return userStore{
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,
},
},
}
}
// ExampleClientID is only used in the example server
func (u userStore) ExampleClientID() string {
return "service"
}
func (u userStore) GetUserByID(id string) *User {
return u.users[id]
}
func (u userStore) GetUserByUsername(username string) *User {
for _, user := range u.users {
if user.Username == username {
return user
}
}
return nil
}