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