Compare commits

..

31 commits
v0.3.0 ... main

Author SHA1 Message Date
David Allen
e0a8d43421
Fixed token fetch from IDP 2024-07-01 12:29:31 -06:00
David Allen
a7e0e73e45
Added response body print to debug ID token 2024-07-01 12:29:31 -06:00
David Allen
8c01ba897f
Added verbose print to show ID and access tokens from IDP 2024-07-01 12:29:31 -06:00
David Allen
a0cca97e7d
Merge pull request #13 from opencube-horizon/bugfix/token-handler
server: fix error reporting and logic for /keys handler
2024-05-28 08:32:47 -06:00
Tiziano Müller
b304361ce9
server: fix error reporting and logic for /keys handler
restores proper error reporting to include the host dialed, and
fixes the tautological comparison `jwks == nil` in the recovery path
to properly refetch the server config and try again as intended
2024-05-27 10:28:53 +02:00
David J. Allen
e929fac09e
Fixed some minor issues 2024-04-30 16:03:23 -06:00
David J. Allen
7022801fe9
Implemented IDP registered clients and callbacks 2024-04-30 14:44:57 -06:00
David J. Allen
cbb3e6f851
Updated login page hint 2024-04-30 14:43:50 -06:00
David J. Allen
bc5e693425
Changed the IDP oauth endpoints to oauth2 2024-04-30 12:45:02 -06:00
David J. Allen
2edc624c01
Resetted the default IDP endpoint values 2024-04-30 12:28:19 -06:00
David J. Allen
e940dc2dd9
Fixed IDP endpoint overrides not working correctly 2024-04-30 11:32:28 -06:00
David J. Allen
67683e9fca
Fixed small issues with not building 2024-04-30 09:01:05 -06:00
David J. Allen
73e4e50d44
Made it possible to override certain example IDP endpoints 2024-04-29 18:45:30 -06:00
David J. Allen
f10a771db6
Added missing IDP function back 2024-04-29 14:58:51 -06:00
David J. Allen
c67c6f75a2
Added audience override for token sent to authorization server 2024-04-29 14:52:25 -06:00
20ba7bc735
Separated server and IDP code into different files 2024-04-28 13:23:25 -06:00
David J. Allen
447c9fb5e9
Reverted token fetch in server 2024-04-26 15:56:25 -06:00
David Allen
5825273f0c
Merge pull request #12 from OpenCHAMI/main
Update main
2024-04-26 15:44:02 -06:00
David Allen
5aefc2ffcf
Merge branch 'davidallendj:main' into main 2024-04-26 15:42:56 -06:00
David Allen
890a268e11
Merge pull request #11 from OpenCHAMI/login
Changed login button to use <a href> tags with no JS
2024-04-26 15:41:20 -06:00
David J. Allen
20206ea4c1
Changed login button to use <a href> tags with no JS 2024-04-26 15:37:09 -06:00
Alex Lovell-Troy
0dc5754b4d
Backing off arm64 build 2024-04-24 14:08:15 -04:00
Alex Lovell-Troy
78ef71e94c
Update build_release.yml
Removing broken attestation for the moment and adding a manual workflow trigger
2024-04-24 13:59:06 -04:00
David Allen
8d3e1085c8
Merge pull request #3 from synackd/add-arm64
Goreleaser: Add arm64 builds
2024-04-24 11:12:52 -06:00
Devon Bautista
bea426c47d Goreleaser: Add arm64 builds 2024-04-24 10:04:15 -06:00
David Allen
5650cf6985
Merge pull request #10 from davidallendj/refactor-login
Refactor login
2024-04-23 13:20:12 -06:00
David J. Allen
6d2f488a6b
Refactored login page and process 2024-04-23 13:17:41 -06:00
David J. Allen
61a35c165d
WIP refactoring login 2024-04-18 16:02:43 -06:00
David J. Allen
2e117bea36
Removed write headers for .well-known endpoints 2024-04-18 12:55:38 -06:00
David J. Allen
2762a95da5
Update README.md about internal IDP 2024-04-18 12:55:16 -06:00
David J. Allen
b45821e587
Changed .well-known endpoints to write status codes 2024-04-18 12:40:25 -06:00
15 changed files with 636 additions and 473 deletions

View file

@ -4,6 +4,7 @@
name: Release with goreleaser name: Release with goreleaser
on: on:
workflow_dispatch:
push: push:
tags: tags:
- v* - v*
@ -52,7 +53,3 @@ jobs:
echo "fs.writeFileSync('digest.txt', firstNonNullDigest);" >> process.js echo "fs.writeFileSync('digest.txt', firstNonNullDigest);" >> process.js
node process.js node process.js
echo "digest=$(cat digest.txt)" >> $GITHUB_OUTPUT echo "digest=$(cat digest.txt)" >> $GITHUB_OUTPUT
- name: Attest opaal binary
uses: github-early-access/generate-build-provenance@main
with:
subject-path: opaal

View file

@ -53,4 +53,4 @@ changelog:
# github: # github:
# name_template: "{{.Version}}" # name_template: "{{.Version}}"
# prerelease: auto # prerelease: auto
# mode: append # mode: append

View file

@ -28,12 +28,39 @@ These commands will create a default config, then start the login process. Maybe
- [Gitlab](https://about.gitlab.com/) - [Gitlab](https://about.gitlab.com/)
- [Forgejo](https://forgejo.org/) (fork of Gitea) - [Forgejo](https://forgejo.org/) (fork of Gitea)
The tool is now able to run an internal example identity provider using the `serve` subcommand.
```bash
./opaal serve --config config.yaml
```
This will start a server that allows you to login with `opaal` itself. Currently, it is only has one example user to use for log in. The username and password combination is `ochami:ochami`. It uses the same config file as before with additional parameters set in the config file:
```yaml
server:
...
issuer:
host: "127.0.0.1"
port: 3332
authentication:
clients:
- id: "ochami"
secret: "ochami"
name: "ochami"
issuer: "http://127.0.0.1:3332"
redirect-uris:
- "http://127.0.0.1:3333/oidc/callback"
```
See the [Configuration](#configuration) section for the entire config file.
### Authorization Code Flow ### Authorization Code Flow
`opaal` has the ability to completely execute the authorization code and return an access token from an authorization server using social sign-in. The process works as follows: `opaal` has the ability to completely execute the authorization code and return an access token from an authorization server using social sign-in. The process works as follows:
1. Click the authorization link or navigate to the hosted endpoint in your browser (127.0.0.1:3333 by default) 1. Click the authorization link or navigate to the hosted endpoint in your browser (127.0.0.1:3333 by default)
- Alternatively, you can use a link produced - Alternatively, you can use a link produced
2. Login using identity provider credentials 2. Login using identity provider credentials
3. Authorize application registered with IdP 3. Authorize application registered with IdP
4. IdP redirects to specified redirect URI 4. IdP redirects to specified redirect URI
@ -41,27 +68,29 @@ These commands will create a default config, then start the login process. Maybe
- ...verifying the authenticity of the ID token from identity provider with its JWKS - ...verifying the authenticity of the ID token from identity provider with its JWKS
- ...adds itself as a trusted issuer to the authorization server with it's own JWK - ...adds itself as a trusted issuer to the authorization server with it's own JWK
- ...creates a new signed JWT to send to the authorization server with the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type - ...creates a new signed JWT to send to the authorization server with the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type
- ... returns an access token that can be used by services protected by the authorization server - ... returns an access token that can be used by services protected by the authorization server
*After receiving the ID token, the rest of the flow requires the appropriate URLs to be set to continue. *After receiving the ID token, the rest of the flow requires the appropriate URLs to be set to continue.
### Client Credentials Flow ### Client Credentials Flow
`opaal` also has
## Configuration ## Configuration
Here is an example configuration file: Here is an example configuration file:
```yaml ```yaml
version: "0.0.1" version: "0.3.2"
server: server:
host: "127.0.0.1" host: "127.0.0.1"
port: 3333 port: 3333
callback: "/oidc/callback" callback: "/oidc/callback"
issuer:
host: "127.0.0.1"
port: 3332
providers: providers:
opaal: "https://127.0.0.1:3332"
forgejo: "http://127.0.0.1:3000" forgejo: "http://127.0.0.1:3000"
authentication: authentication:
@ -83,7 +112,17 @@ authentication:
client-credentials: client-credentials:
authorization: authorization:
urls: token:
forwarding: false
refresh: false
duration: 16h
scope:
- smd.read
key-path: ./keys
endpoints:
issuer: http://127.0.0.1:4444
config: http://127.0.0.1:4444/.well-known/openid-configuration
jwks: http://127.0.0.1:4444/.well-known/jwks.json
#identities: http://127.0.0.1:4434/admin/identities #identities: http://127.0.0.1:4434/admin/identities
trusted-issuers: http://127.0.0.1:4445/admin/trust/grants/jwt-bearer/issuers trusted-issuers: http://127.0.0.1:4445/admin/trust/grants/jwt-bearer/issuers
login: http://127.0.0.1:4433/self-service/login/api login: http://127.0.0.1:4433/self-service/login/api
@ -91,17 +130,14 @@ authorization:
authorize: http://127.0.0.1:4444/oauth2/auth authorize: http://127.0.0.1:4444/oauth2/auth
register: http://127.0.0.1:4444/oauth2/register register: http://127.0.0.1:4444/oauth2/register
token: http://127.0.0.1:4444/oauth2/token token: http://127.0.0.1:4444/oauth2/token
clients:
- id: bss
secret: IAMBSS
options: options:
decode-id-token: true
decode-access-token: true
run-once: true run-once: true
open-browser: false open-browser: false
forward: false flow: authorization_code
cache-only: false
verbose: true
``` ```
## Troubleshooting ## Troubleshooting

View file

@ -2,12 +2,9 @@ package cmd
import ( import (
opaal "davidallendj/opaal/internal" opaal "davidallendj/opaal/internal"
cache "davidallendj/opaal/internal/cache/sqlite"
"davidallendj/opaal/internal/oauth" "davidallendj/opaal/internal/oauth"
"davidallendj/opaal/internal/oidc"
"fmt" "fmt"
"os" "os"
"slices"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -25,68 +22,68 @@ var loginCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
for { for {
// try and find client with valid identity provider config // try and find client with valid identity provider config
var provider *oidc.IdentityProvider // var provider *oidc.IdentityProvider
if target != "" { // if target != "" {
// only try to use client with name give // // only try to use client with name give
index := slices.IndexFunc(config.Authentication.Clients, func(c oauth.Client) bool { // index := slices.IndexFunc(config.Authentication.Clients, func(c oauth.Client) bool {
return target == c.Name // return target == c.Name
}) // })
if index < 0 { // if index < 0 {
fmt.Printf("could not find the target client listed by name") // fmt.Printf("could not find the target client listed by name")
os.Exit(1) // os.Exit(1)
} // }
client := config.Authentication.Clients[index] // client := config.Authentication.Clients[index]
_, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer) // _, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer)
if err != nil { // if err != nil {
} // }
} else if targetIndex >= 0 { // } else if targetIndex >= 0 {
// only try to use client by index // // only try to use client by index
targetCount := len(config.Authentication.Clients) - 1 // targetCount := len(config.Authentication.Clients) - 1
if targetIndex > targetCount { // if targetIndex > targetCount {
fmt.Printf("target index out of range (found %d)", targetCount) // fmt.Printf("target index out of range (found %d)", targetCount)
} // }
client := config.Authentication.Clients[targetIndex] // client := config.Authentication.Clients[targetIndex]
_, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer) // _, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer)
if err != nil { // if err != nil {
} // }
} else { // } else {
for _, c := range config.Authentication.Clients { // for _, c := range config.Authentication.Clients {
// try to get identity provider info locally first // // try to get identity provider info locally first
_, err := cache.GetIdentityProvider(config.Options.CachePath, c.Issuer) // _, err := cache.GetIdentityProvider(config.Options.CachePath, c.Issuer)
if err != nil && !config.Options.CacheOnly { // if err != nil && !config.Options.CacheOnly {
fmt.Printf("fetching config from issuer: %v\n", c.Issuer) // fmt.Printf("fetching config from issuer: %v\n", c.Issuer)
// try to get info remotely by fetching // // try to get info remotely by fetching
provider, err = oidc.FetchServerConfig(c.Issuer) // provider, err = oidc.FetchServerConfig(c.Issuer)
if err != nil { // if err != nil {
fmt.Printf("failed to fetch server config: %v\n", err) // fmt.Printf("failed to fetch server config: %v\n", err)
continue // continue
} // }
client = c // client = c
// fetch the provider's JWKS // // fetch the provider's JWKS
err := provider.FetchJwks() // err := provider.FetchJwks()
if err != nil { // if err != nil {
fmt.Printf("failed to fetch JWKS: %v\n", err) // fmt.Printf("failed to fetch JWKS: %v\n", err)
} // }
break // break
} // }
// only test the first if --run-all flag is not set // // only test the first if --run-all flag is not set
if !config.Authentication.TestAllClients { // if !config.Authentication.TestAllClients {
fmt.Printf("stopping after first test...\n\n\n") // fmt.Printf("stopping after first test...\n\n\n")
break // break
} // }
} // }
} // }
if provider == nil { // if provider == nil {
fmt.Printf("failed to retrieve provider config\n") // fmt.Printf("failed to retrieve provider config\n")
os.Exit(1) // os.Exit(1)
} // }
// start the listener // start the listener
err := opaal.Login(&config, &client, provider) err := opaal.Login(&config)
if err != nil { if err != nil {
fmt.Printf("%v\n", err) fmt.Printf("%v\n", err)
os.Exit(1) os.Exit(1)
@ -115,3 +112,13 @@ func init() {
loginCmd.MarkFlagsMutuallyExclusive("target.name", "target.index") loginCmd.MarkFlagsMutuallyExclusive("target.name", "target.index")
rootCmd.AddCommand(loginCmd) rootCmd.AddCommand(loginCmd)
} }
func MakeButton(url string, text string) string {
// check if we have http:// a
html := "<input type=\"button\" "
html += "class=\"button\" "
html += fmt.Sprintf("onclick=\"window.location.href='%s';\" ", url)
html += fmt.Sprintf("value=\"%s\"", text)
return html
// return "<a href=\"" + url + "\"> " + text + "</a>"
}

View file

@ -2,6 +2,7 @@ package cmd
import ( import (
opaal "davidallendj/opaal/internal" opaal "davidallendj/opaal/internal"
"davidallendj/opaal/internal/oidc"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@ -9,9 +10,13 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var exampleCmd = &cobra.Command{ var (
endpoints oidc.Endpoints
)
var serveCmd = &cobra.Command{
Use: "serve", Use: "serve",
Short: "Start an simple identity provider server", Short: "Start an simple, bare minimal identity provider server",
Long: "The built-in identity provider is not (nor meant to be) a complete OIDC implementation and behaves like an external IdP", Long: "The built-in identity provider is not (nor meant to be) a complete OIDC implementation and behaves like an external IdP",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
s := opaal.NewServerWithConfig(&config) s := opaal.NewServerWithConfig(&config)
@ -21,11 +26,15 @@ var exampleCmd = &cobra.Command{
if errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
fmt.Printf("Identity provider server closed.\n") fmt.Printf("Identity provider server closed.\n")
} else if err != nil { } else if err != nil {
fmt.Errorf("failed to start server: %v", err) fmt.Printf("failed to start server: %v", err)
} }
}, },
} }
func init() { func init() {
rootCmd.AddCommand(exampleCmd) serveCmd.Flags().StringVar(&endpoints.Authorization, "endpoints.authorization", "", "set the authorization endpoint for the identity provider")
serveCmd.Flags().StringVar(&endpoints.Token, "endpoints.token", "", "set the token endpoint for the identity provider")
serveCmd.Flags().StringVar(&endpoints.JwksUri, "endpoints.jwks_uri", "", "set the JWKS endpoints for the identity provider")
rootCmd.AddCommand(serveCmd)
} }

View file

@ -2,6 +2,7 @@ package opaal
import ( import (
"davidallendj/opaal/internal/oauth" "davidallendj/opaal/internal/oauth"
"davidallendj/opaal/internal/oidc"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@ -45,6 +46,7 @@ type TokenOptions struct {
Forwarding bool `yaml:"forwarding"` Forwarding bool `yaml:"forwarding"`
Refresh bool `yaml:"refresh"` Refresh bool `yaml:"refresh"`
Scope []string `yaml:"scope"` Scope []string `yaml:"scope"`
//TODO: allow specifying audience in returned token
} }
type Authentication struct { type Authentication struct {
@ -55,9 +57,10 @@ type Authentication struct {
} }
type Authorization struct { type Authorization struct {
Token TokenOptions `yaml:"token"`
Endpoints Endpoints `yaml:"endpoints"` Endpoints Endpoints `yaml:"endpoints"`
KeyPath string `yaml:"key-path"` KeyPath string `yaml:"key-path"`
Token TokenOptions `yaml:"token"` Audience []string `yaml:"audience"` // NOTE: overrides the "aud" claim in token sent to authorization server
} }
type Config struct { type Config struct {
@ -70,11 +73,18 @@ type Config struct {
} }
func NewConfig() Config { func NewConfig() Config {
return Config{ config := Config{
Version: goutil.GetCommit(), Version: goutil.GetCommit(),
Server: server.Server{ Server: server.Server{
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 3333, Port: 3333,
Issuer: server.IdentityProviderServer{
Endpoints: oidc.Endpoints{
Authorization: "",
Token: "",
JwksUri: "",
},
},
}, },
Options: Options{ Options: Options{
RunOnce: true, RunOnce: true,
@ -97,6 +107,7 @@ func NewConfig() Config {
}, },
}, },
} }
return config
} }
func LoadConfig(path string) Config { func LoadConfig(path string) Config {

View file

@ -4,7 +4,6 @@ import (
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"davidallendj/opaal/internal/oauth" "davidallendj/opaal/internal/oauth"
"davidallendj/opaal/internal/oidc"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@ -19,14 +18,15 @@ import (
) )
type JwtBearerFlowParams struct { type JwtBearerFlowParams struct {
AccessToken string AccessToken string
IdToken string IdToken string
IdentityProvider *oidc.IdentityProvider // IdentityProvider *oidc.IdentityProvider
TrustedIssuer *oauth.TrustedIssuer TrustedIssuer *oauth.TrustedIssuer
Client *oauth.Client Client *oauth.Client
Refresh bool Audience []string
Verbose bool Refresh bool
KeyPath string Verbose bool
KeyPath string
} }
type JwtBearerFlowEndpoints struct { type JwtBearerFlowEndpoints struct {
@ -39,22 +39,30 @@ type JwtBearerFlowEndpoints struct {
func NewJwtBearerFlow(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) (string, error) { func NewJwtBearerFlow(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) (string, error) {
// 1. verify that the JWT from the issuer is valid using all keys // 1. verify that the JWT from the issuer is valid using all keys
var ( var (
idp = params.IdentityProvider // idp = params.IdentityProvider
accessToken = params.AccessToken accessToken = params.AccessToken
idToken = params.IdToken idToken = params.IdToken
client = params.Client client = params.Client
trustedIssuer = params.TrustedIssuer trustedIssuer = params.TrustedIssuer
verbose = params.Verbose verbose = params.Verbose
) )
// pre-condition checks to make sure certain variables are set
if client == nil {
return "", fmt.Errorf("invalid client (client is nil)")
}
if verbose {
fmt.Printf("ID token (IDP): %s\n access token (IDP): %s", accessToken, idToken)
}
if accessToken != "" { if accessToken != "" {
_, err := jws.Verify([]byte(accessToken), jws.WithKeySet(idp.KeySet), jws.WithValidateKey(true)) _, err := jws.Verify([]byte(accessToken), jws.WithKeySet(client.Provider.KeySet), jws.WithValidateKey(true))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to verify access token: %v", err) return "", fmt.Errorf("failed to verify access token: %v", err)
} }
} }
if idToken != "" { if idToken != "" {
_, err := jws.Verify([]byte(idToken), jws.WithKeySet(idp.KeySet), jws.WithValidateKey(true)) _, err := jws.Verify([]byte(idToken), jws.WithKeySet(client.Provider.KeySet), jws.WithValidateKey(true))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to verify ID token: %v", err) return "", fmt.Errorf("failed to verify ID token: %v", err)
} }
@ -126,7 +134,7 @@ func NewJwtBearerFlow(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) (s
// TODO: add trusted issuer to cache if successful // TODO: add trusted issuer to cache if successful
// 4. create a new JWT based on the claims from the identity provider and sign // 4. create a new JWT based on the claims from the identity provider and sign
parsedIdToken, err := jwt.ParseString(idToken, jwt.WithKeySet(idp.KeySet)) parsedIdToken, err := jwt.ParseString(idToken, jwt.WithKeySet(client.Provider.KeySet))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to parse ID token: %v", err) return "", fmt.Errorf("failed to parse ID token: %v", err)
} }
@ -139,6 +147,11 @@ func NewJwtBearerFlow(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) (s
payload["exp"] = time.Now().Add(time.Second * 3600 * 16).Unix() payload["exp"] = time.Now().Add(time.Second * 3600 * 16).Unix()
payload["sub"] = "opaal" payload["sub"] = "opaal"
// if an "audience" value is set, then override the token endpoint value
if len(params.Audience) > 0 {
payload["aud"] = params.Audience
}
// include the offline_access scope if refresh tokens are enabled // include the offline_access scope if refresh tokens are enabled
if params.Refresh { if params.Refresh {
v, ok := payload["scope"] v, ok := payload["scope"]
@ -242,7 +255,7 @@ func ForwardToken(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) error
var ( var (
client = params.Client client = params.Client
idToken = params.IdToken idToken = params.IdToken
idp = params.IdentityProvider // idp = params.IdentityProvider
verbose = params.Verbose verbose = params.Verbose
) )
@ -250,7 +263,7 @@ func ForwardToken(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) error
if verbose { if verbose {
fmt.Printf("Fetching JWKS from authentication server for verification...\n") fmt.Printf("Fetching JWKS from authentication server for verification...\n")
} }
err := idp.FetchJwks() err := client.Provider.FetchJwks()
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch JWK: %v", err) return fmt.Errorf("failed to fetch JWK: %v", err)
} else { } else {
@ -260,7 +273,7 @@ func ForwardToken(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) error
} }
ti := &oauth.TrustedIssuer{ ti := &oauth.TrustedIssuer{
Issuer: idp.Issuer, Issuer: client.Provider.Issuer,
Subject: "1", Subject: "1",
ExpiresAt: time.Now().Add(time.Second * 3600), ExpiresAt: time.Now().Add(time.Second * 3600),
} }

View file

@ -12,19 +12,11 @@ import (
"time" "time"
) )
func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider) error { func Login(config *Config) error {
if config == nil { if config == nil {
return fmt.Errorf("invalid config") return fmt.Errorf("invalid config")
} }
if client == nil {
return fmt.Errorf("invalid client")
}
if provider == nil {
return fmt.Errorf("invalid identity provider")
}
// make cache if it's not where expect // make cache if it's not where expect
_, err := cache.CreateIdentityProvidersIfNotExists(config.Options.CachePath) _, err := cache.CreateIdentityProvidersIfNotExists(config.Options.CachePath)
if err != nil { if err != nil {
@ -39,25 +31,20 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
} }
// print the authorization URL for sharing // print the authorization URL for sharing
var authorizationUrl = client.BuildAuthorizationUrl(provider.Endpoints.Authorization, state)
s := NewServerWithConfig(config) s := NewServerWithConfig(config)
fmt.Printf("Login with external identity provider:\n\n %s/login\n %s\n\n", s.State = state
s.GetListenAddr(), authorizationUrl,
)
var button = MakeButton(authorizationUrl, "Login with "+client.Name)
var authzClient = oauth.NewClient() var authzClient = oauth.NewClient()
authzClient.Scope = config.Authorization.Token.Scope authzClient.Scope = config.Authorization.Token.Scope
// authorize oauth client and listen for callback from provider
fmt.Printf("Waiting for authorization code redirect @%s/oidc/callback...\n", s.GetListenAddr())
params := server.ServerParams{ params := server.ServerParams{
Verbose: config.Options.Verbose, Verbose: config.Options.Verbose,
AuthProvider: &oidc.IdentityProvider{ AuthProvider: &oidc.IdentityProvider{
Issuer: config.Authorization.Endpoints.Issuer, Issuer: config.Authorization.Endpoints.Issuer,
Endpoints: oidc.Endpoints{ Endpoints: oidc.Endpoints{
Config: config.Authorization.Endpoints.Config, Config: config.Authorization.Endpoints.Config,
JwksUri: config.Authorization.Endpoints.JwksUri, Authorization: config.Authorization.Endpoints.Authorize,
JwksUri: config.Authorization.Endpoints.JwksUri,
}, },
}, },
JwtBearerEndpoints: flows.JwtBearerFlowEndpoints{ JwtBearerEndpoints: flows.JwtBearerFlowEndpoints{
@ -66,8 +53,7 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
Register: config.Authorization.Endpoints.Register, Register: config.Authorization.Endpoints.Register,
}, },
JwtBearerParams: flows.JwtBearerFlowParams{ JwtBearerParams: flows.JwtBearerFlowParams{
Client: authzClient, Client: authzClient,
IdentityProvider: provider,
TrustedIssuer: &oauth.TrustedIssuer{ TrustedIssuer: &oauth.TrustedIssuer{
AllowAnySubject: false, AllowAnySubject: false,
Issuer: s.Addr, Issuer: s.Addr,
@ -75,8 +61,9 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
ExpiresAt: time.Now().Add(config.Authorization.Token.Duration), ExpiresAt: time.Now().Add(config.Authorization.Token.Duration),
Scope: []string{}, Scope: []string{},
}, },
Verbose: config.Options.Verbose, Verbose: config.Options.Verbose,
Refresh: config.Authorization.Token.Refresh, Refresh: config.Authorization.Token.Refresh,
Audience: config.Authorization.Audience,
}, },
ClientCredentialsEndpoints: flows.ClientCredentialsFlowEndpoints{ ClientCredentialsEndpoints: flows.ClientCredentialsFlowEndpoints{
Clients: config.Authorization.Endpoints.Clients, Clients: config.Authorization.Endpoints.Clients,
@ -87,7 +74,7 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
Client: authzClient, Client: authzClient,
}, },
} }
err = s.StartLogin(button, provider, client, params) err = s.StartLogin(config.Authentication.Clients, params)
if errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
fmt.Printf("\n=========================================\nServer closed.\n=========================================\n\n") fmt.Printf("\n=========================================\nServer closed.\n=========================================\n\n")
} else if err != nil { } else if err != nil {
@ -96,7 +83,7 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
} else if config.Options.FlowType == "client_credentials" { } else if config.Options.FlowType == "client_credentials" {
params := flows.ClientCredentialsFlowParams{ params := flows.ClientCredentialsFlowParams{
Client: client, Client: nil, // # FIXME: need to do something about this being nil I think
} }
_, err := NewClientCredentialsFlowWithConfig(config, params) _, err := NewClientCredentialsFlowWithConfig(config, params)
if err != nil { if err != nil {
@ -108,13 +95,3 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
return nil return nil
} }
func MakeButton(url string, text string) string {
// check if we have http:// a
html := "<input type=\"button\" "
html += "class=\"button\" "
html += fmt.Sprintf("onclick=\"window.location.href='%s';\" ", url)
html += fmt.Sprintf("value=\"%s\"", text)
return html
// return "<a href=\"" + url + "\"> " + text + "</a>"
}

View file

@ -29,7 +29,7 @@ func NewClientWithConfig(config *Config) *oauth.Client {
Id: clients[0].Id, Id: clients[0].Id,
Secret: clients[0].Secret, Secret: clients[0].Secret,
Name: clients[0].Name, Name: clients[0].Name,
Issuer: clients[0].Issuer, Provider: clients[0].Provider,
Scope: clients[0].Scope, Scope: clients[0].Scope,
RedirectUris: clients[0].RedirectUris, RedirectUris: clients[0].RedirectUris,
} }
@ -53,7 +53,7 @@ func NewClientWithConfigByName(config *Config, name string) *oauth.Client {
func NewClientWithConfigByProvider(config *Config, issuer string) *oauth.Client { func NewClientWithConfigByProvider(config *Config, issuer string) *oauth.Client {
index := slices.IndexFunc(config.Authentication.Clients, func(c oauth.Client) bool { index := slices.IndexFunc(config.Authentication.Clients, func(c oauth.Client) bool {
return c.Issuer == issuer return c.Provider.Issuer == issuer
}) })
if index >= 0 { if index >= 0 {
@ -90,9 +90,11 @@ func NewServerWithConfig(conf *Config) *server.Server {
}, },
Host: host, Host: host,
Port: port, Port: port,
Issuer: server.Issuer{ Issuer: server.IdentityProviderServer{
Host: conf.Server.Issuer.Host, Host: conf.Server.Issuer.Host,
Port: conf.Server.Issuer.Port, Port: conf.Server.Issuer.Port,
Endpoints: conf.Server.Issuer.Endpoints,
Clients: conf.Server.Issuer.Clients,
}, },
} }
return server return server

View file

@ -16,12 +16,15 @@ func (client *Client) IsFlowInitiated() bool {
return client.FlowId != "" return client.FlowId != ""
} }
func (client *Client) BuildAuthorizationUrl(issuer string, state string) string { func (client *Client) BuildAuthorizationUrl(state string) string {
return issuer + "?" + "client_id=" + client.Id + url := client.Provider.Endpoints.Authorization + "?client_id=" + client.Id +
"&redirect_uri=" + url.QueryEscape(strings.Join(client.RedirectUris, ",")) + "&redirect_uri=" + url.QueryEscape(strings.Join(client.RedirectUris, ",")) +
"&response_type=code" + // this has to be set to "code" "&response_type=code" + // this has to be set to "code"
"&state=" + state +
"&scope=" + strings.Join(client.Scope, "+") "&scope=" + strings.Join(client.Scope, "+")
if state != "" {
url += "&state=" + state
}
return url
} }
func (client *Client) InitiateLoginFlow(loginUrl string) error { func (client *Client) InitiateLoginFlow(loginUrl string) error {
@ -90,7 +93,7 @@ func (client *Client) FetchCSRFToken(flowUrl string) error {
return fmt.Errorf("failed to extract CSRF token: not found") return fmt.Errorf("failed to extract CSRF token: not found")
} }
func (client *Client) FetchTokenFromAuthenticationServer(code string, remoteUrl string, state string) ([]byte, error) { func (client *Client) FetchTokenFromAuthenticationServer(code string, state string) ([]byte, error) {
body := url.Values{ body := url.Values{
"grant_type": {"authorization_code"}, "grant_type": {"authorization_code"},
"client_id": {client.Id}, "client_id": {client.Id},
@ -104,14 +107,16 @@ func (client *Client) FetchTokenFromAuthenticationServer(code string, remoteUrl
if state != "" { if state != "" {
body["state"] = []string{state} body["state"] = []string{state}
} }
res, err := http.PostForm(remoteUrl, body) res, err := http.PostForm(client.Provider.Endpoints.Token, body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ID token: %s", err) return nil, fmt.Errorf("failed to get ID token: %v", err)
} }
b, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %v", err)
}
fmt.Printf("%s\n", string(b))
defer res.Body.Close() defer res.Body.Close()
// domain, _ := url.Parse("http://127.0.0.1") return b, nil
// client.Jar.SetCookies(domain, res.Cookies())
return io.ReadAll(res.Body)
} }

View file

@ -1,6 +1,7 @@
package oauth package oauth
import ( import (
"davidallendj/opaal/internal/oidc"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -24,15 +25,15 @@ const (
type Client struct { type Client struct {
http.Client http.Client
Id string `db:"id" yaml:"id"` Id string `db:"id" yaml:"id"`
Secret string `db:"secret" yaml:"secret"` Secret string `db:"secret" yaml:"secret"`
Name string `db:"name" yaml:"name"` Name string `db:"name" yaml:"name"`
Description string `db:"description" yaml:"description"` Description string `db:"description" yaml:"description"`
Issuer string `db:"issuer" yaml:"issuer"` Provider oidc.IdentityProvider `db:"issuer" yaml:"provider"`
RegistrationAccessToken string `db:"registration_access_token" yaml:"registration-access-token"` RegistrationAccessToken string `db:"registration_access_token" yaml:"registration-access-token"`
RedirectUris []string `db:"redirect_uris" yaml:"redirect-uris"` RedirectUris []string `db:"redirect_uris" yaml:"redirect-uris"`
Scope []string `db:"scope" yaml:"scope"` Scope []string `db:"scope" yaml:"scope"`
Audience []string `db:"audience" yaml:"audience"` Audience []string `db:"audience" yaml:"audience"`
FlowId string FlowId string
CsrfToken string CsrfToken string
} }

View file

@ -111,26 +111,11 @@ func (p *IdentityProvider) LoadServerConfig(path string) error {
} }
func (p *IdentityProvider) FetchServerConfig() error { func (p *IdentityProvider) FetchServerConfig() error {
// make a request to a server's openid-configuration tmp, err := FetchServerConfig(p.Issuer)
req, err := http.NewRequest(http.MethodGet, p.Issuer+"/.well-known/openid-configuration", bytes.NewBuffer([]byte{}))
if err != nil { if err != nil {
return fmt.Errorf("failed to create a new request: %v", err) return err
}
client := &http.Client{} // temp client to get info and not used in flow
res, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to do request: %v", err)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %v", err)
}
err = p.ParseServerConfig(body)
if err != nil {
return fmt.Errorf("failed to parse server config: %v", err)
} }
p = tmp
return nil return nil
} }
@ -147,10 +132,15 @@ func FetchServerConfig(issuer string) (*IdentityProvider, error) {
return nil, fmt.Errorf("failed to do request: %v", err) return nil, fmt.Errorf("failed to do request: %v", err)
} }
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP status code: %d", res.StatusCode)
}
body, err := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read response body: %v", err) return nil, fmt.Errorf("failed to read response body: %v", err)
} }
var p IdentityProvider var p IdentityProvider
err = p.ParseServerConfig(body) err = p.ParseServerConfig(body)
if err != nil { if err != nil {
@ -174,3 +164,25 @@ func (p *IdentityProvider) FetchJwks() error {
return nil return nil
} }
func (p *IdentityProvider) UpdateEndpoints(other *IdentityProvider) {
UpdateEndpoints(&p.Endpoints, &other.Endpoints)
}
func UpdateEndpoints(eps *Endpoints, other *Endpoints) {
// only update endpoints that are not empty
var UpdateIfEmpty = func(ep *string, s string) {
if ep != nil {
if *ep == "" {
*ep = s
}
}
}
UpdateIfEmpty(&eps.Config, other.Config)
UpdateIfEmpty(&eps.Authorization, other.Authorization)
UpdateIfEmpty(&eps.Token, other.Token)
UpdateIfEmpty(&eps.Revocation, other.Revocation)
UpdateIfEmpty(&eps.Introspection, other.Introspection)
UpdateIfEmpty(&eps.UserInfo, other.UserInfo)
UpdateIfEmpty(&eps.JwksUri, other.JwksUri)
}

290
internal/server/idp.go Normal file
View file

@ -0,0 +1,290 @@
package server
import (
"crypto/rand"
"crypto/rsa"
"davidallendj/opaal/internal/oidc"
"encoding/json"
"fmt"
"net/http"
"os"
"slices"
"strings"
"time"
"github.com/davidallendj/go-utils/cryptox"
"github.com/davidallendj/go-utils/util"
"github.com/go-chi/chi/v5"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt"
)
// TODO: make this a completely separate server
type IdentityProviderServer struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Endpoints oidc.Endpoints `yaml:"endpoints"`
Clients []RegisteredClient `yaml:"clients"`
}
// NOTE: could we use a oauth.Client here instead??
type RegisteredClient struct {
Id string `yaml:"id"`
Secret string `yaml:"secret"`
Name string `yaml:"name"`
RedirectUris []string `yaml:"redirect-uris"`
}
func (s *Server) StartIdentityProvider() error {
// NOTE: this example does NOT implement CSRF tokens nor use them
// create an example identity provider
var (
r = chi.NewRouter()
// clients = []oauth.Client{}
activeCodes = []string{}
)
// update endpoints that have values set
defaultEps := oidc.Endpoints{
Authorization: "http://" + s.Addr + "/oauth2/authorize",
Token: "http://" + s.Addr + "/oauth2/token",
JwksUri: "http://" + s.Addr + "/.well-known/jwks.json",
}
oidc.UpdateEndpoints(&s.Issuer.Endpoints, &defaultEps)
// generate key pair used to sign JWKS and create JWTs
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("failed to generate new RSA key: %v", err)
}
privateJwk, publicJwk, err := cryptox.GenerateJwkKeyPairFromPrivateKey(privateKey)
if err != nil {
return fmt.Errorf("failed to generate JWK pair from private key: %v", err)
}
kid, _ := privateJwk.Get("kid")
publicJwk.Set("kid", kid)
publicJwk.Set("use", "sig")
publicJwk.Set("kty", "RSA")
publicJwk.Set("alg", "RS256")
if err := publicJwk.Validate(); err != nil {
return fmt.Errorf("failed to validate public JWK: %v", err)
}
// TODO: create .well-known JWKS endpoint with json
r.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
// TODO: generate new JWKs from a private key
jwks := map[string]any{
"keys": []jwk.Key{
publicJwk,
},
}
b, err := json.Marshal(jwks)
if err != nil {
return
}
w.Write(b)
})
r.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
// create config JSON to serve with GET request
config := map[string]any{
"issuer": "http://" + s.Addr,
"authorization_endpoint": s.Issuer.Endpoints.Authorization,
"token_endpoint": s.Issuer.Endpoints.Token,
"jwks_uri": s.Issuer.Endpoints.JwksUri,
"scopes_supported": []string{
"openid",
"profile",
"email",
},
"response_types_supported": []string{
"code",
},
"grant_types_supported": []string{
"authorization_code",
},
"id_token_signing_alg_values_supported": []string{
"RS256",
},
"claims_supported": []string{
"iss",
"sub",
"aud",
"exp",
"iat",
"name",
"email",
},
}
b, err := json.Marshal(config)
if err != nil {
return
}
w.Write(b)
})
r.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
// serve up a simple login page
})
r.HandleFunc("/consent", func(w http.ResponseWriter, r *http.Request) {
// give consent for app to use
})
r.HandleFunc("/browser/login", func(w http.ResponseWriter, r *http.Request) {
// serve up a login page for user creds
form, err := os.ReadFile("pages/login.html")
if err != nil {
fmt.Printf("failed to load login form: %v", err)
}
w.Write(form)
})
r.HandleFunc("/api/login", func(w http.ResponseWriter, r *http.Request) {
// check for example identity with POST request
r.ParseForm()
username := r.Form.Get("username")
password := r.Form.Get("password")
if len(s.Issuer.Clients) <= 0 {
fmt.Printf("no registered clients found with identity provider (add them in config)\n")
return
}
// example username and password so do simplified authorization code flow
if username == "openchami" && password == "openchami" {
client := s.Issuer.Clients[0]
// check if there are any redirect URIs supplied
if len(client.RedirectUris) <= 0 {
fmt.Printf("no redirect URIs found for client %s (ID: %s)\n", client.Name, client.Id)
return
}
for _, url := range client.RedirectUris {
// send an authorization code to each URI
code := util.RandomString(64)
activeCodes = append(activeCodes, code)
redirectUrl := fmt.Sprintf("%s?code=%s", url, code)
fmt.Printf("redirect URL: %s\n", redirectUrl)
http.Redirect(w, r, redirectUrl, http.StatusFound)
// _, _, err := httpx.MakeHttpRequest(fmt.Sprintf("%s?code=%s", url, code), http.MethodGet, nil, nil)
// if err != nil {
// fmt.Printf("failed to make request: %v\n", err)
// continue
// }
}
} else {
w.Write([]byte("error logging in"))
http.Redirect(w, r, "/browser/login", http.StatusUnauthorized)
}
})
r.HandleFunc("/oauth2/token", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
// check for authorization code and make sure it's valid
var code = r.Form.Get("code")
index := slices.IndexFunc(activeCodes, func(s string) bool { return s == code })
if index < 0 {
fmt.Printf("invalid authorization code: %s\n", code)
return
}
// now create and return a JWT that can be verified with by authorization server
iat := time.Now().Unix()
exp := time.Now().Add(time.Second * 3600 * 16).Unix()
t := jwt.New()
t.Set(jwt.IssuerKey, s.Addr)
t.Set(jwt.SubjectKey, "ochami")
t.Set(jwt.AudienceKey, "ochami")
t.Set(jwt.IssuedAtKey, iat)
t.Set(jwt.ExpirationKey, exp)
t.Set("name", "ochami")
t.Set("email", "example@ochami.org")
t.Set("email_verified", true)
t.Set("scope", []string{
"openid",
"profile",
"email",
"example",
})
// payload := map[string]any{}
// payload["iss"] = s.Addr
// payload["aud"] = "ochami"
// payload["iat"] = iat
// payload["nbf"] = iat
// payload["exp"] = exp
// payload["sub"] = "ochami"
// payload["name"] = "ochami"
// payload["email"] = "example@ochami.org"
// payload["email_verified"] = true
// payload["scope"] = []string{
// "openid",
// "profile",
// "email",
// "example",
// }
payloadJson, err := json.MarshalIndent(t, "", "\t")
if err != nil {
fmt.Printf("failed to marshal payload: %v", err)
return
}
signed, err := jws.Sign(payloadJson, jws.WithKey(jwa.RS256, privateJwk))
if err != nil {
fmt.Printf("failed to sign token: %v\n", err)
return
}
// construct the bearer token with required fields
scope, _ := t.Get("scope")
bearer := map[string]any{
"token_type": "Bearer",
"id_token": string(signed),
"expires_in": exp,
"created_at": iat,
"scope": strings.Join(scope.([]string), " "),
}
b, err := json.MarshalIndent(bearer, "", "\t")
if err != nil {
fmt.Printf("failed to marshal bearer token: %v\n", err)
return
}
fmt.Printf("bearer: %s\n", string(b))
w.Write(b)
})
r.HandleFunc("/oauth2/authorize", func(w http.ResponseWriter, r *http.Request) {
var (
responseType = r.URL.Query().Get("response_type")
clientId = r.URL.Query().Get("client_id")
redirectUris = r.URL.Query().Get("redirect_uri")
)
// check for required authorization code params
if responseType != "code" {
fmt.Printf("invalid response type\n")
return
}
// find a valid client
index := slices.IndexFunc(s.Issuer.Clients, func(c RegisteredClient) bool {
fmt.Printf("%s ? %s\n", c.Id, clientId)
return c.Id == clientId
})
if index < 0 {
fmt.Printf("no valid client found")
return
}
// TODO: check that our redirect URIs all match
for _, uri := range redirectUris {
_ = uri
}
// redirect to browser login since we don't do session management here
http.Redirect(w, r, "/browser/login", http.StatusFound)
})
s.Handler = r
return s.ListenAndServe()
}

View file

@ -1,44 +1,28 @@
package server package server
import ( import (
"crypto/rand"
"crypto/rsa"
"davidallendj/opaal/internal/flows" "davidallendj/opaal/internal/flows"
"davidallendj/opaal/internal/oauth" "davidallendj/opaal/internal/oauth"
"davidallendj/opaal/internal/oidc" "davidallendj/opaal/internal/oidc"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"slices" "slices"
"strings"
"time"
"github.com/davidallendj/go-utils/cryptox"
"github.com/davidallendj/go-utils/httpx" "github.com/davidallendj/go-utils/httpx"
"github.com/davidallendj/go-utils/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/nikolalohinski/gonja/v2" "github.com/nikolalohinski/gonja/v2"
"github.com/nikolalohinski/gonja/v2/exec" "github.com/nikolalohinski/gonja/v2/exec"
) )
type Server struct { type Server struct {
*http.Server *http.Server
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
Callback string `yaml:"callback"` Callback string `yaml:"callback"`
State string `yaml:"state"` State string `yaml:"state"`
Issuer Issuer `yaml:"issuer"` Issuer IdentityProviderServer `yaml:"issuer"`
}
type Issuer struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
} }
type ServerParams struct { type ServerParams struct {
@ -58,10 +42,12 @@ func (s *Server) GetListenAddr() string {
return fmt.Sprintf("%s:%d", s.Host, s.Port) return fmt.Sprintf("%s:%d", s.Host, s.Port)
} }
func (s *Server) StartLogin(buttons string, provider *oidc.IdentityProvider, client *oauth.Client, params ServerParams) error { func (s *Server) StartLogin(clients []oauth.Client, params ServerParams) error {
var ( var (
target = "" target string
callback = "" callback string
client *oauth.Client
sso string
) )
// check if callback is set // check if callback is set
@ -69,6 +55,28 @@ func (s *Server) StartLogin(buttons string, provider *oidc.IdentityProvider, cli
callback = "/oidc/callback" callback = "/oidc/callback"
} }
// make the login page SSO buttons and authorization URLs to write to stdout
buttons := ""
fmt.Printf("Login with an identity provider: \n")
for i, client := range clients {
// fetch provider configuration before adding button
p, err := oidc.FetchServerConfig(client.Provider.Issuer)
if err != nil {
fmt.Printf("failed to fetch server config: %v\n", err)
continue
}
// if we're able to get the config, go ahead and try to fetch jwks too
if err = p.FetchJwks(); err != nil {
fmt.Printf("failed to fetch JWKS: %v\n", err)
continue
}
clients[i].Provider = *p
buttons += makeButton(fmt.Sprintf("/login?sso=%s", client.Id), client.Name)
fmt.Printf("\t%s: /login?sso=%s\n", client.Name, client.Id)
}
var code string var code string
var accessToken string var accessToken string
r := chi.NewRouter() r := chi.NewRouter()
@ -93,20 +101,34 @@ func (s *Server) StartLogin(buttons string, provider *oidc.IdentityProvider, cli
// add target if query exists // add target if query exists
if r != nil { if r != nil {
target = r.URL.Query().Get("target") target = r.URL.Query().Get("target")
sso = r.URL.Query().Get("sso")
// TODO: get client from list and build the authorization URL string
index := slices.IndexFunc(clients, func(c oauth.Client) bool {
return c.Id == sso
})
// TODO: redirect the user to authorization URL and return from func
foundClient := index >= 0
if foundClient {
client = &clients[index]
url := client.BuildAuthorizationUrl(s.State)
if params.Verbose {
fmt.Printf("Redirect URL: %s\n", url)
}
http.Redirect(w, r, url, http.StatusFound)
return
}
} }
// show login page with notice to redirect // show login page with notice to redirect
template, err := gonja.FromFile("pages/index.html") template, err := gonja.FromFile("pages/index.html")
if err != nil { if err != nil {
panic(err) panic(err)
} }
// form, err := os.ReadFile("pages/login.html")
// if err != nil {
// fmt.Printf("failed to load login form: %v", err)
// }
data := exec.NewContext(map[string]interface{}{ data := exec.NewContext(map[string]interface{}{
// "loginForm": string(form),
"loginButtons": buttons, "loginButtons": buttons,
}) })
@ -119,45 +141,54 @@ func (s *Server) StartLogin(buttons string, provider *oidc.IdentityProvider, cli
p = params.AuthProvider p = params.AuthProvider
jwks []byte jwks []byte
) )
// try and get the JWKS from param first
if p.Endpoints.JwksUri != "" { fetchAndMarshal := func() (err error) {
err := p.FetchJwks() err = p.FetchJwks()
if err != nil { if err != nil {
fmt.Printf("failed to fetch keys using JWKS url...trying to fetch config and try again...\n") fmt.Printf("failed to fetch keys: %v\n", err)
return
} }
jwks, err = json.Marshal(p.KeySet) jwks, err = json.Marshal(p.KeySet)
if err != nil { if err != nil {
fmt.Printf("failed to marshal JWKS: %v\n", err) fmt.Printf("failed to marshal JWKS: %v\n", err)
} }
} else if p.Endpoints.Config != "" && jwks == nil { return
// otherwise, try and fetch the whole config and try again }
err := p.FetchServerConfig()
if err != nil { // try and get the JWKS from param first
fmt.Printf("failed to fetch server config: %v\n", err) if p.Endpoints.JwksUri != "" {
http.Redirect(w, r, "/error", http.StatusInternalServerError) if err := fetchAndMarshal(); err != nil {
return w.Write(jwks)
}
err = p.FetchJwks()
if err != nil {
fmt.Printf("failed to fetch JWKS after fetching server config: %v\n", err)
http.Redirect(w, r, "/error", http.StatusInternalServerError)
return return
} }
} }
// forward the JWKS from the authorization server // otherwise or if fetching the JWKS failed, try and fetch the whole config first and try again
if jwks == nil { if p.Endpoints.Config != "" {
fmt.Printf("no JWKS was fetched from authorization server\n") if err := p.FetchServerConfig(); err != nil {
http.Redirect(w, r, "/error", http.StatusInternalServerError) fmt.Printf("failed to fetch server config: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
} else {
fmt.Printf("getting JWKS from param failed and endpoints config unavailable\n")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
if err := fetchAndMarshal(); err != nil {
fmt.Printf("failed to fetch and marshal JWKS after config update: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Write(jwks) w.Write(jwks)
}) })
r.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
// use refresh token provided to do a refresh token grant // use refresh token provided to do a refresh token grant
refreshToken := r.URL.Query().Get("refresh-token") refreshToken := r.URL.Query().Get("refresh-token")
if refreshToken != "" { if refreshToken != "" {
_, err := params.JwtBearerParams.Client.PerformRefreshTokenGrant(provider.Endpoints.Token, refreshToken) _, err := params.JwtBearerParams.Client.PerformRefreshTokenGrant(client.Provider.Endpoints.Token, refreshToken)
if err != nil { if err != nil {
fmt.Printf("failed to perform refresh token grant: %v\n", err) fmt.Printf("failed to perform refresh token grant: %v\n", err)
http.Redirect(w, r, "/error", http.StatusInternalServerError) http.Redirect(w, r, "/error", http.StatusInternalServerError)
@ -176,6 +207,8 @@ func (s *Server) StartLogin(buttons string, provider *oidc.IdentityProvider, cli
return return
} }
} else { } else {
// FIXME: I think this probably needs to reworked or removed
// NOTE: this logic fetches a token for services to retrieve like BSS
// perform a client credentials grant and return a token // perform a client credentials grant and return a token
var err error var err error
accessToken, err = flows.NewClientCredentialsFlow(params.ClientCredentialsEndpoints, params.ClientCredentialsParams) accessToken, err = flows.NewClientCredentialsFlow(params.ClientCredentialsEndpoints, params.ClientCredentialsParams)
@ -195,10 +228,15 @@ func (s *Server) StartLogin(buttons string, provider *oidc.IdentityProvider, cli
fmt.Printf("Authorization code: %v\n", code) fmt.Printf("Authorization code: %v\n", code)
} }
// make sure we have the correct client to use
if client == nil {
fmt.Printf("failed to find valid client")
return
}
// use code from response and exchange for bearer token (with ID token) // use code from response and exchange for bearer token (with ID token)
bearerToken, err := client.FetchTokenFromAuthenticationServer( bearerToken, err := client.FetchTokenFromAuthenticationServer(
code, code,
provider.Endpoints.Token,
s.State, s.State,
) )
if err != nil { if err != nil {
@ -228,6 +266,7 @@ func (s *Server) StartLogin(buttons string, provider *oidc.IdentityProvider, cli
// complete JWT bearer flow to receive access token from authorization server // complete JWT bearer flow to receive access token from authorization server
// fmt.Printf("bearer: %v\n", string(bearerToken)) // fmt.Printf("bearer: %v\n", string(bearerToken))
params.JwtBearerParams.IdToken = data["id_token"].(string) params.JwtBearerParams.IdToken = data["id_token"].(string)
params.JwtBearerParams.Client = client
accessToken, err = flows.NewJwtBearerFlow(params.JwtBearerEndpoints, params.JwtBearerParams) accessToken, err = flows.NewJwtBearerFlow(params.JwtBearerEndpoints, params.JwtBearerParams)
if err != nil { if err != nil {
fmt.Printf("failed to complete JWT bearer flow: %v\n", err) fmt.Printf("failed to complete JWT bearer flow: %v\n", err)
@ -294,250 +333,14 @@ func (s *Server) StartLogin(buttons string, provider *oidc.IdentityProvider, cli
return s.ListenAndServe() return s.ListenAndServe()
} }
func (s *Server) StartIdentityProvider() error { func makeButton(url string, text string) string {
// NOTE: this example does NOT implement CSRF tokens nor use them // check if we have http:// a
// html := "<input type=\"button\" "
// create an example identity provider // html += fmt.Sprintf("onclick=\"window.location.href='%s';\" ", url)
var ( // html += fmt.Sprintf("value=\"%s\">", text)
r = chi.NewRouter() html := "<a "
// clients = []oauth.Client{} html += "class=\"button\" "
callback = "" html += fmt.Sprintf("href=\"%s\">%s", url, text)
activeCodes = []string{} html += "</a>"
) return html
// check if callback is set
if s.Callback == "" {
callback = "/oidc/callback"
}
// generate key pair used to sign JWKS and create JWTs
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("failed to generate new RSA key: %v", err)
}
privateJwk, publicJwk, err := cryptox.GenerateJwkKeyPairFromPrivateKey(privateKey)
if err != nil {
return fmt.Errorf("failed to generate JWK pair from private key: %v", err)
}
kid, _ := privateJwk.Get("kid")
publicJwk.Set("kid", kid)
publicJwk.Set("use", "sig")
publicJwk.Set("kty", "RSA")
publicJwk.Set("alg", "RS256")
if err := publicJwk.Validate(); err != nil {
return fmt.Errorf("failed to validate public JWK: %v", err)
}
// TODO: create .well-known JWKS endpoint with json
r.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
// TODO: generate new JWKs from a private key
jwks := map[string]any{
"keys": []jwk.Key{
publicJwk,
},
}
b, err := json.Marshal(jwks)
if err != nil {
return
}
w.Write(b)
})
// TODO: create .well-known openid configuration
r.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
// create config JSON to serve with GET request
config := map[string]any{
"issuer": "http://" + s.Addr,
"authorization_endpoint": "http://" + s.Addr + "/oauth/authorize",
"token_endpoint": "http://" + s.Addr + "/oauth/token",
"jwks_uri": "http://" + s.Addr + "/.well-known/jwks.json",
"scopes_supported": []string{
"openid",
"profile",
"email",
},
"response_types_supported": []string{
"code",
},
"grant_types_supported": []string{
"authorization_code",
},
"id_token_signing_alg_values_supported": []string{
"RS256",
},
"claims_supported": []string{
"iss",
"sub",
"aud",
"exp",
"iat",
"name",
"email",
},
}
b, err := json.Marshal(config)
if err != nil {
return
}
w.Write(b)
})
r.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
// serve up a simple login page
})
r.HandleFunc("/consent", func(w http.ResponseWriter, r *http.Request) {
// give consent for app to use
})
r.HandleFunc("/browser/login", func(w http.ResponseWriter, r *http.Request) {
// serve up a login page for user creds
form, err := os.ReadFile("pages/login.html")
if err != nil {
fmt.Printf("failed to load login form: %v", err)
}
w.Write(form)
})
r.HandleFunc("/api/login", func(w http.ResponseWriter, r *http.Request) {
// check for example identity with POST request
r.ParseForm()
username := r.Form.Get("username")
password := r.Form.Get("password")
// example username and password so do simplified authorization code flow
if username == "ochami" && password == "ochami" {
client := oauth.Client{
Id: "ochami",
Secret: "ochami",
Name: "ochami",
Issuer: "http://127.0.0.1:3333",
RedirectUris: []string{fmt.Sprintf("http://%s:%d%s", s.Host, s.Port, callback)},
}
// check if there are any redirect URIs supplied
if len(client.RedirectUris) <= 0 {
fmt.Printf("no redirect URIs found")
return
}
for _, url := range client.RedirectUris {
// send an authorization code to each URI
code := util.RandomString(64)
activeCodes = append(activeCodes, code)
redirectUrl := fmt.Sprintf("%s?code=%s", url, code)
fmt.Printf("redirect URL: %s\n", redirectUrl)
http.Redirect(w, r, redirectUrl, http.StatusFound)
// _, _, err := httpx.MakeHttpRequest(fmt.Sprintf("%s?code=%s", url, code), http.MethodGet, nil, nil)
// if err != nil {
// fmt.Printf("failed to make request: %v\n", err)
// continue
// }
}
} else {
w.Write([]byte("error logging in"))
http.Redirect(w, r, "/browser/login", http.StatusUnauthorized)
}
})
r.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
// check for authorization code and make sure it's valid
var code = r.Form.Get("code")
index := slices.IndexFunc(activeCodes, func(s string) bool { return s == code })
if index < 0 {
fmt.Printf("invalid authorization code: %s\n", code)
return
}
// now create and return a JWT that can be verified with by authorization server
iat := time.Now().Unix()
exp := time.Now().Add(time.Second * 3600 * 16).Unix()
t := jwt.New()
t.Set(jwt.IssuerKey, s.Addr)
t.Set(jwt.SubjectKey, "ochami")
t.Set(jwt.AudienceKey, "ochami")
t.Set(jwt.IssuedAtKey, iat)
t.Set(jwt.ExpirationKey, exp)
t.Set("name", "ochami")
t.Set("email", "example@ochami.org")
t.Set("email_verified", true)
t.Set("scope", []string{
"openid",
"profile",
"email",
"example",
})
// payload := map[string]any{}
// payload["iss"] = s.Addr
// payload["aud"] = "ochami"
// payload["iat"] = iat
// payload["nbf"] = iat
// payload["exp"] = exp
// payload["sub"] = "ochami"
// payload["name"] = "ochami"
// payload["email"] = "example@ochami.org"
// payload["email_verified"] = true
// payload["scope"] = []string{
// "openid",
// "profile",
// "email",
// "example",
// }
payloadJson, err := json.MarshalIndent(t, "", "\t")
if err != nil {
fmt.Printf("failed to marshal payload: %v", err)
return
}
signed, err := jws.Sign(payloadJson, jws.WithKey(jwa.RS256, privateJwk))
if err != nil {
fmt.Printf("failed to sign token: %v\n", err)
return
}
// construct the bearer token with required fields
scope, _ := t.Get("scope")
bearer := map[string]any{
"token_type": "Bearer",
"id_token": string(signed),
"expires_in": exp,
"created_at": iat,
"scope": strings.Join(scope.([]string), " "),
}
b, err := json.MarshalIndent(bearer, "", "\t")
if err != nil {
fmt.Printf("failed to marshal bearer token: %v\n", err)
return
}
fmt.Printf("bearer: %s\n", string(b))
w.Write(b)
})
r.HandleFunc("/oauth/authorize", func(w http.ResponseWriter, r *http.Request) {
var (
responseType = r.URL.Query().Get("response_type")
clientId = r.URL.Query().Get("client_id")
redirectUris = r.URL.Query().Get("redirect_uri")
)
// check for required authorization code params
if responseType != "code" {
fmt.Printf("invalid response type\n")
return
}
// check that we're using the default registered client
if clientId != "ochami" {
fmt.Printf("invalid client\n")
return
}
// TODO: check that our redirect URIs all match
for _, uri := range redirectUris {
_ = uri
}
// redirect to browser login since we don't do session management here
http.Redirect(w, r, "/browser/login", http.StatusFound)
})
s.Handler = r
return s.ListenAndServe()
} }

View file

@ -7,7 +7,7 @@
<input type="password" id="password" name="password" title="password" placeholder="Enter your password..." /><br/> <input type="password" id="password" name="password" title="password" placeholder="Enter your password..." /><br/>
<button type="submit" class="btn">Login</button><br/> <button type="submit" class="btn">Login</button><br/>
<a class="forgot" href="#">Forgot Username?</a><br/> <a class="forgot" href="#">Forgot Username?</a><br/>
<label>(hint: try 'ochami' for both username and password)</label> <label>(hint: try 'openchami' for both username and password)</label>
</form> </form>
</div><!--end log form --> </div><!--end log form -->
</html> </html>