diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index a1336da..5b7ff5e 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -4,7 +4,6 @@ name: Release with goreleaser on: - workflow_dispatch: push: tags: - v* @@ -53,3 +52,7 @@ jobs: echo "fs.writeFileSync('digest.txt', firstNonNullDigest);" >> process.js node process.js echo "digest=$(cat digest.txt)" >> $GITHUB_OUTPUT + - name: Attest opaal binary + uses: github-early-access/generate-build-provenance@main + with: + subject-path: opaal \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8e9d87d..72956fa 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -53,4 +53,4 @@ changelog: # github: # name_template: "{{.Version}}" # prerelease: auto -# mode: append +# mode: append \ No newline at end of file diff --git a/README.md b/README.md index 1ca42c7..bcabb1b 100644 --- a/README.md +++ b/README.md @@ -28,39 +28,12 @@ These commands will create a default config, then start the login process. Maybe - [Gitlab](https://about.gitlab.com/) - [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 `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) - - Alternatively, you can use a link produced + - Alternatively, you can use a link produced 2. Login using identity provider credentials 3. Authorize application registered with IdP 4. IdP redirects to specified redirect URI @@ -68,29 +41,27 @@ See the [Configuration](#configuration) section for the entire config file. - ...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 - ...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. ### Client Credentials Flow +`opaal` also has + ## Configuration Here is an example configuration file: ```yaml -version: "0.3.2" +version: "0.0.1" server: host: "127.0.0.1" port: 3333 callback: "/oidc/callback" - issuer: - host: "127.0.0.1" - port: 3332 providers: - opaal: "https://127.0.0.1:3332" forgejo: "http://127.0.0.1:3000" authentication: @@ -112,17 +83,7 @@ authentication: client-credentials: authorization: - 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 + urls: #identities: http://127.0.0.1:4434/admin/identities 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 @@ -130,14 +91,17 @@ authorization: authorize: http://127.0.0.1:4444/oauth2/auth register: http://127.0.0.1:4444/oauth2/register token: http://127.0.0.1:4444/oauth2/token + clients: + - id: bss + secret: IAMBSS options: + decode-id-token: true + decode-access-token: true run-once: true open-browser: false - flow: authorization_code - cache-only: false - verbose: true + forward: false ``` ## Troubleshooting diff --git a/cmd/login.go b/cmd/login.go index 855a0e8..abe3452 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -2,9 +2,12 @@ package cmd import ( opaal "davidallendj/opaal/internal" + cache "davidallendj/opaal/internal/cache/sqlite" "davidallendj/opaal/internal/oauth" + "davidallendj/opaal/internal/oidc" "fmt" "os" + "slices" "github.com/spf13/cobra" ) @@ -22,68 +25,68 @@ var loginCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { for { // try and find client with valid identity provider config - // var provider *oidc.IdentityProvider - // if target != "" { - // // only try to use client with name give - // index := slices.IndexFunc(config.Authentication.Clients, func(c oauth.Client) bool { - // return target == c.Name - // }) - // if index < 0 { - // fmt.Printf("could not find the target client listed by name") - // os.Exit(1) - // } - // client := config.Authentication.Clients[index] - // _, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer) - // if err != nil { + var provider *oidc.IdentityProvider + if target != "" { + // only try to use client with name give + index := slices.IndexFunc(config.Authentication.Clients, func(c oauth.Client) bool { + return target == c.Name + }) + if index < 0 { + fmt.Printf("could not find the target client listed by name") + os.Exit(1) + } + client := config.Authentication.Clients[index] + _, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer) + if err != nil { - // } + } - // } else if targetIndex >= 0 { - // // only try to use client by index - // targetCount := len(config.Authentication.Clients) - 1 - // if targetIndex > targetCount { - // fmt.Printf("target index out of range (found %d)", targetCount) - // } - // client := config.Authentication.Clients[targetIndex] - // _, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer) - // if err != nil { + } else if targetIndex >= 0 { + // only try to use client by index + targetCount := len(config.Authentication.Clients) - 1 + if targetIndex > targetCount { + fmt.Printf("target index out of range (found %d)", targetCount) + } + client := config.Authentication.Clients[targetIndex] + _, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer) + if err != nil { - // } - // } else { - // for _, c := range config.Authentication.Clients { - // // try to get identity provider info locally first - // _, err := cache.GetIdentityProvider(config.Options.CachePath, c.Issuer) - // if err != nil && !config.Options.CacheOnly { - // fmt.Printf("fetching config from issuer: %v\n", c.Issuer) - // // try to get info remotely by fetching - // provider, err = oidc.FetchServerConfig(c.Issuer) - // if err != nil { - // fmt.Printf("failed to fetch server config: %v\n", err) - // continue - // } - // client = c - // // fetch the provider's JWKS - // err := provider.FetchJwks() - // if err != nil { - // fmt.Printf("failed to fetch JWKS: %v\n", err) - // } - // break - // } - // // only test the first if --run-all flag is not set - // if !config.Authentication.TestAllClients { - // fmt.Printf("stopping after first test...\n\n\n") - // break - // } - // } - // } + } + } else { + for _, c := range config.Authentication.Clients { + // try to get identity provider info locally first + _, err := cache.GetIdentityProvider(config.Options.CachePath, c.Issuer) + if err != nil && !config.Options.CacheOnly { + fmt.Printf("fetching config from issuer: %v\n", c.Issuer) + // try to get info remotely by fetching + provider, err = oidc.FetchServerConfig(c.Issuer) + if err != nil { + fmt.Printf("failed to fetch server config: %v\n", err) + continue + } + client = c + // fetch the provider's JWKS + err := provider.FetchJwks() + if err != nil { + fmt.Printf("failed to fetch JWKS: %v\n", err) + } + break + } + // only test the first if --run-all flag is not set + if !config.Authentication.TestAllClients { + fmt.Printf("stopping after first test...\n\n\n") + break + } + } + } - // if provider == nil { - // fmt.Printf("failed to retrieve provider config\n") - // os.Exit(1) - // } + if provider == nil { + fmt.Printf("failed to retrieve provider config\n") + os.Exit(1) + } // start the listener - err := opaal.Login(&config) + err := opaal.Login(&config, &client, provider) if err != nil { fmt.Printf("%v\n", err) os.Exit(1) @@ -112,13 +115,3 @@ func init() { loginCmd.MarkFlagsMutuallyExclusive("target.name", "target.index") rootCmd.AddCommand(loginCmd) } - -func MakeButton(url string, text string) string { - // check if we have http:// a - html := " " + text + "" -} diff --git a/cmd/serve.go b/cmd/serve.go index b68b814..67cec1a 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -2,7 +2,6 @@ package cmd import ( opaal "davidallendj/opaal/internal" - "davidallendj/opaal/internal/oidc" "errors" "fmt" "net/http" @@ -10,13 +9,9 @@ import ( "github.com/spf13/cobra" ) -var ( - endpoints oidc.Endpoints -) - -var serveCmd = &cobra.Command{ +var exampleCmd = &cobra.Command{ Use: "serve", - Short: "Start an simple, bare minimal identity provider server", + Short: "Start an simple 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", Run: func(cmd *cobra.Command, args []string) { s := opaal.NewServerWithConfig(&config) @@ -26,15 +21,11 @@ var serveCmd = &cobra.Command{ if errors.Is(err, http.ErrServerClosed) { fmt.Printf("Identity provider server closed.\n") } else if err != nil { - fmt.Printf("failed to start server: %v", err) + fmt.Errorf("failed to start server: %v", err) } }, } func init() { - 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) + rootCmd.AddCommand(exampleCmd) } diff --git a/internal/config.go b/internal/config.go index 9108ac1..1515013 100644 --- a/internal/config.go +++ b/internal/config.go @@ -2,7 +2,6 @@ package opaal import ( "davidallendj/opaal/internal/oauth" - "davidallendj/opaal/internal/oidc" "log" "os" "path/filepath" @@ -46,7 +45,6 @@ type TokenOptions struct { Forwarding bool `yaml:"forwarding"` Refresh bool `yaml:"refresh"` Scope []string `yaml:"scope"` - //TODO: allow specifying audience in returned token } type Authentication struct { @@ -57,10 +55,9 @@ type Authentication struct { } type Authorization struct { - Token TokenOptions `yaml:"token"` Endpoints Endpoints `yaml:"endpoints"` KeyPath string `yaml:"key-path"` - Audience []string `yaml:"audience"` // NOTE: overrides the "aud" claim in token sent to authorization server + Token TokenOptions `yaml:"token"` } type Config struct { @@ -73,18 +70,11 @@ type Config struct { } func NewConfig() Config { - config := Config{ + return Config{ Version: goutil.GetCommit(), Server: server.Server{ Host: "127.0.0.1", Port: 3333, - Issuer: server.IdentityProviderServer{ - Endpoints: oidc.Endpoints{ - Authorization: "", - Token: "", - JwksUri: "", - }, - }, }, Options: Options{ RunOnce: true, @@ -107,7 +97,6 @@ func NewConfig() Config { }, }, } - return config } func LoadConfig(path string) Config { diff --git a/internal/flows/jwt_bearer.go b/internal/flows/jwt_bearer.go index a0287d9..652944b 100644 --- a/internal/flows/jwt_bearer.go +++ b/internal/flows/jwt_bearer.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "crypto/rsa" "davidallendj/opaal/internal/oauth" + "davidallendj/opaal/internal/oidc" "encoding/json" "fmt" "os" @@ -18,15 +19,14 @@ import ( ) type JwtBearerFlowParams struct { - AccessToken string - IdToken string - // IdentityProvider *oidc.IdentityProvider - TrustedIssuer *oauth.TrustedIssuer - Client *oauth.Client - Audience []string - Refresh bool - Verbose bool - KeyPath string + AccessToken string + IdToken string + IdentityProvider *oidc.IdentityProvider + TrustedIssuer *oauth.TrustedIssuer + Client *oauth.Client + Refresh bool + Verbose bool + KeyPath string } type JwtBearerFlowEndpoints struct { @@ -39,30 +39,22 @@ type JwtBearerFlowEndpoints struct { func NewJwtBearerFlow(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) (string, error) { // 1. verify that the JWT from the issuer is valid using all keys var ( - // idp = params.IdentityProvider + idp = params.IdentityProvider accessToken = params.AccessToken idToken = params.IdToken client = params.Client trustedIssuer = params.TrustedIssuer 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 != "" { - _, err := jws.Verify([]byte(accessToken), jws.WithKeySet(client.Provider.KeySet), jws.WithValidateKey(true)) + _, err := jws.Verify([]byte(accessToken), jws.WithKeySet(idp.KeySet), jws.WithValidateKey(true)) if err != nil { return "", fmt.Errorf("failed to verify access token: %v", err) } } if idToken != "" { - _, err := jws.Verify([]byte(idToken), jws.WithKeySet(client.Provider.KeySet), jws.WithValidateKey(true)) + _, err := jws.Verify([]byte(idToken), jws.WithKeySet(idp.KeySet), jws.WithValidateKey(true)) if err != nil { return "", fmt.Errorf("failed to verify ID token: %v", err) } @@ -134,7 +126,7 @@ func NewJwtBearerFlow(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) (s // TODO: add trusted issuer to cache if successful // 4. create a new JWT based on the claims from the identity provider and sign - parsedIdToken, err := jwt.ParseString(idToken, jwt.WithKeySet(client.Provider.KeySet)) + parsedIdToken, err := jwt.ParseString(idToken, jwt.WithKeySet(idp.KeySet)) if err != nil { return "", fmt.Errorf("failed to parse ID token: %v", err) } @@ -147,11 +139,6 @@ func NewJwtBearerFlow(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) (s payload["exp"] = time.Now().Add(time.Second * 3600 * 16).Unix() 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 if params.Refresh { v, ok := payload["scope"] @@ -255,7 +242,7 @@ func ForwardToken(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) error var ( client = params.Client idToken = params.IdToken - // idp = params.IdentityProvider + idp = params.IdentityProvider verbose = params.Verbose ) @@ -263,7 +250,7 @@ func ForwardToken(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) error if verbose { fmt.Printf("Fetching JWKS from authentication server for verification...\n") } - err := client.Provider.FetchJwks() + err := idp.FetchJwks() if err != nil { return fmt.Errorf("failed to fetch JWK: %v", err) } else { @@ -273,7 +260,7 @@ func ForwardToken(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) error } ti := &oauth.TrustedIssuer{ - Issuer: client.Provider.Issuer, + Issuer: idp.Issuer, Subject: "1", ExpiresAt: time.Now().Add(time.Second * 3600), } diff --git a/internal/login.go b/internal/login.go index b6aaf84..e9c6374 100644 --- a/internal/login.go +++ b/internal/login.go @@ -12,11 +12,19 @@ import ( "time" ) -func Login(config *Config) error { +func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider) error { if config == nil { 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 _, err := cache.CreateIdentityProvidersIfNotExists(config.Options.CachePath) if err != nil { @@ -31,20 +39,25 @@ func Login(config *Config) error { } // print the authorization URL for sharing + var authorizationUrl = client.BuildAuthorizationUrl(provider.Endpoints.Authorization, state) s := NewServerWithConfig(config) - s.State = state + fmt.Printf("Login with external identity provider:\n\n %s/login\n %s\n\n", + s.GetListenAddr(), authorizationUrl, + ) + var button = MakeButton(authorizationUrl, "Login with "+client.Name) var authzClient = oauth.NewClient() 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{ Verbose: config.Options.Verbose, AuthProvider: &oidc.IdentityProvider{ Issuer: config.Authorization.Endpoints.Issuer, Endpoints: oidc.Endpoints{ - Config: config.Authorization.Endpoints.Config, - Authorization: config.Authorization.Endpoints.Authorize, - JwksUri: config.Authorization.Endpoints.JwksUri, + Config: config.Authorization.Endpoints.Config, + JwksUri: config.Authorization.Endpoints.JwksUri, }, }, JwtBearerEndpoints: flows.JwtBearerFlowEndpoints{ @@ -53,7 +66,8 @@ func Login(config *Config) error { Register: config.Authorization.Endpoints.Register, }, JwtBearerParams: flows.JwtBearerFlowParams{ - Client: authzClient, + Client: authzClient, + IdentityProvider: provider, TrustedIssuer: &oauth.TrustedIssuer{ AllowAnySubject: false, Issuer: s.Addr, @@ -61,9 +75,8 @@ func Login(config *Config) error { ExpiresAt: time.Now().Add(config.Authorization.Token.Duration), Scope: []string{}, }, - Verbose: config.Options.Verbose, - Refresh: config.Authorization.Token.Refresh, - Audience: config.Authorization.Audience, + Verbose: config.Options.Verbose, + Refresh: config.Authorization.Token.Refresh, }, ClientCredentialsEndpoints: flows.ClientCredentialsFlowEndpoints{ Clients: config.Authorization.Endpoints.Clients, @@ -74,7 +87,7 @@ func Login(config *Config) error { Client: authzClient, }, } - err = s.StartLogin(config.Authentication.Clients, params) + err = s.StartLogin(button, provider, client, params) if errors.Is(err, http.ErrServerClosed) { fmt.Printf("\n=========================================\nServer closed.\n=========================================\n\n") } else if err != nil { @@ -83,7 +96,7 @@ func Login(config *Config) error { } else if config.Options.FlowType == "client_credentials" { params := flows.ClientCredentialsFlowParams{ - Client: nil, // # FIXME: need to do something about this being nil I think + Client: client, } _, err := NewClientCredentialsFlowWithConfig(config, params) if err != nil { @@ -95,3 +108,13 @@ func Login(config *Config) error { return nil } + +func MakeButton(url string, text string) string { + // check if we have http:// a + html := " " + text + "" +} diff --git a/internal/new.go b/internal/new.go index 2799d88..2b2bad8 100644 --- a/internal/new.go +++ b/internal/new.go @@ -29,7 +29,7 @@ func NewClientWithConfig(config *Config) *oauth.Client { Id: clients[0].Id, Secret: clients[0].Secret, Name: clients[0].Name, - Provider: clients[0].Provider, + Issuer: clients[0].Issuer, Scope: clients[0].Scope, RedirectUris: clients[0].RedirectUris, } @@ -53,7 +53,7 @@ func NewClientWithConfigByName(config *Config, name string) *oauth.Client { func NewClientWithConfigByProvider(config *Config, issuer string) *oauth.Client { index := slices.IndexFunc(config.Authentication.Clients, func(c oauth.Client) bool { - return c.Provider.Issuer == issuer + return c.Issuer == issuer }) if index >= 0 { @@ -90,11 +90,9 @@ func NewServerWithConfig(conf *Config) *server.Server { }, Host: host, Port: port, - Issuer: server.IdentityProviderServer{ - Host: conf.Server.Issuer.Host, - Port: conf.Server.Issuer.Port, - Endpoints: conf.Server.Issuer.Endpoints, - Clients: conf.Server.Issuer.Clients, + Issuer: server.Issuer{ + Host: conf.Server.Issuer.Host, + Port: conf.Server.Issuer.Port, }, } return server diff --git a/internal/oauth/authenticate.go b/internal/oauth/authenticate.go index 4af65cb..5724526 100644 --- a/internal/oauth/authenticate.go +++ b/internal/oauth/authenticate.go @@ -16,15 +16,12 @@ func (client *Client) IsFlowInitiated() bool { return client.FlowId != "" } -func (client *Client) BuildAuthorizationUrl(state string) string { - url := client.Provider.Endpoints.Authorization + "?client_id=" + client.Id + +func (client *Client) BuildAuthorizationUrl(issuer string, state string) string { + return issuer + "?" + "client_id=" + client.Id + "&redirect_uri=" + url.QueryEscape(strings.Join(client.RedirectUris, ",")) + "&response_type=code" + // this has to be set to "code" + "&state=" + state + "&scope=" + strings.Join(client.Scope, "+") - if state != "" { - url += "&state=" + state - } - return url } func (client *Client) InitiateLoginFlow(loginUrl string) error { @@ -93,7 +90,7 @@ func (client *Client) FetchCSRFToken(flowUrl string) error { return fmt.Errorf("failed to extract CSRF token: not found") } -func (client *Client) FetchTokenFromAuthenticationServer(code string, state string) ([]byte, error) { +func (client *Client) FetchTokenFromAuthenticationServer(code string, remoteUrl string, state string) ([]byte, error) { body := url.Values{ "grant_type": {"authorization_code"}, "client_id": {client.Id}, @@ -107,16 +104,14 @@ func (client *Client) FetchTokenFromAuthenticationServer(code string, state stri if state != "" { body["state"] = []string{state} } - res, err := http.PostForm(client.Provider.Endpoints.Token, body) + res, err := http.PostForm(remoteUrl, body) if err != nil { - return nil, fmt.Errorf("failed to get ID token: %v", err) + return nil, fmt.Errorf("failed to get ID token: %s", 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() - return b, nil + // domain, _ := url.Parse("http://127.0.0.1") + // client.Jar.SetCookies(domain, res.Cookies()) + + return io.ReadAll(res.Body) } diff --git a/internal/oauth/client.go b/internal/oauth/client.go index 78ab9cc..2040117 100644 --- a/internal/oauth/client.go +++ b/internal/oauth/client.go @@ -1,7 +1,6 @@ package oauth import ( - "davidallendj/opaal/internal/oidc" "encoding/json" "fmt" "net/http" @@ -25,15 +24,15 @@ const ( type Client struct { http.Client - Id string `db:"id" yaml:"id"` - Secret string `db:"secret" yaml:"secret"` - Name string `db:"name" yaml:"name"` - Description string `db:"description" yaml:"description"` - Provider oidc.IdentityProvider `db:"issuer" yaml:"provider"` - RegistrationAccessToken string `db:"registration_access_token" yaml:"registration-access-token"` - RedirectUris []string `db:"redirect_uris" yaml:"redirect-uris"` - Scope []string `db:"scope" yaml:"scope"` - Audience []string `db:"audience" yaml:"audience"` + Id string `db:"id" yaml:"id"` + Secret string `db:"secret" yaml:"secret"` + Name string `db:"name" yaml:"name"` + Description string `db:"description" yaml:"description"` + Issuer string `db:"issuer" yaml:"issuer"` + RegistrationAccessToken string `db:"registration_access_token" yaml:"registration-access-token"` + RedirectUris []string `db:"redirect_uris" yaml:"redirect-uris"` + Scope []string `db:"scope" yaml:"scope"` + Audience []string `db:"audience" yaml:"audience"` FlowId string CsrfToken string } diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index f52e0c4..af813ab 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -111,11 +111,26 @@ func (p *IdentityProvider) LoadServerConfig(path string) error { } func (p *IdentityProvider) FetchServerConfig() error { - tmp, err := FetchServerConfig(p.Issuer) + // make a request to a server's openid-configuration + req, err := http.NewRequest(http.MethodGet, p.Issuer+"/.well-known/openid-configuration", bytes.NewBuffer([]byte{})) if err != nil { - return err + return fmt.Errorf("failed to create a new request: %v", 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 } @@ -132,15 +147,10 @@ func FetchServerConfig(issuer string) (*IdentityProvider, error) { 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) if err != nil { return nil, fmt.Errorf("failed to read response body: %v", err) } - var p IdentityProvider err = p.ParseServerConfig(body) if err != nil { @@ -164,25 +174,3 @@ func (p *IdentityProvider) FetchJwks() error { 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) -} diff --git a/internal/server/idp.go b/internal/server/idp.go deleted file mode 100644 index bb82a28..0000000 --- a/internal/server/idp.go +++ /dev/null @@ -1,290 +0,0 @@ -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() -} diff --git a/internal/server/server.go b/internal/server/server.go index 3fdae97..66c45d4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,28 +1,44 @@ package server import ( + "crypto/rand" + "crypto/rsa" "davidallendj/opaal/internal/flows" "davidallendj/opaal/internal/oauth" "davidallendj/opaal/internal/oidc" "encoding/json" "fmt" "net/http" + "os" "slices" + "strings" + "time" + "github.com/davidallendj/go-utils/cryptox" "github.com/davidallendj/go-utils/httpx" + "github.com/davidallendj/go-utils/util" "github.com/go-chi/chi/v5" "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/exec" ) type Server struct { *http.Server - Host string `yaml:"host"` - Port int `yaml:"port"` - Callback string `yaml:"callback"` - State string `yaml:"state"` - Issuer IdentityProviderServer `yaml:"issuer"` + Host string `yaml:"host"` + Port int `yaml:"port"` + Callback string `yaml:"callback"` + State string `yaml:"state"` + Issuer Issuer `yaml:"issuer"` +} + +type Issuer struct { + Host string `yaml:"host"` + Port int `yaml:"port"` } type ServerParams struct { @@ -42,12 +58,10 @@ func (s *Server) GetListenAddr() string { return fmt.Sprintf("%s:%d", s.Host, s.Port) } -func (s *Server) StartLogin(clients []oauth.Client, params ServerParams) error { +func (s *Server) StartLogin(buttons string, provider *oidc.IdentityProvider, client *oauth.Client, params ServerParams) error { var ( - target string - callback string - client *oauth.Client - sso string + target = "" + callback = "" ) // check if callback is set @@ -55,28 +69,6 @@ func (s *Server) StartLogin(clients []oauth.Client, params ServerParams) error { 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 accessToken string r := chi.NewRouter() @@ -101,34 +93,20 @@ func (s *Server) StartLogin(clients []oauth.Client, params ServerParams) error { // add target if query exists if r != nil { 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 template, err := gonja.FromFile("pages/index.html") if err != nil { 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{}{ + // "loginForm": string(form), "loginButtons": buttons, }) @@ -141,54 +119,45 @@ func (s *Server) StartLogin(clients []oauth.Client, params ServerParams) error { p = params.AuthProvider jwks []byte ) - - fetchAndMarshal := func() (err error) { - err = p.FetchJwks() + // try and get the JWKS from param first + if p.Endpoints.JwksUri != "" { + err := p.FetchJwks() if err != nil { - fmt.Printf("failed to fetch keys: %v\n", err) - return + fmt.Printf("failed to fetch keys using JWKS url...trying to fetch config and try again...\n") } jwks, err = json.Marshal(p.KeySet) if err != nil { fmt.Printf("failed to marshal JWKS: %v\n", err) } - return - } - - // try and get the JWKS from param first - if p.Endpoints.JwksUri != "" { - if err := fetchAndMarshal(); err != nil { - w.Write(jwks) - return - } - } - - // otherwise or if fetching the JWKS failed, try and fetch the whole config first and try again - if p.Endpoints.Config != "" { - if err := p.FetchServerConfig(); err != nil { + } else if p.Endpoints.Config != "" && jwks == nil { + // otherwise, try and fetch the whole config and try again + err := p.FetchServerConfig() + if err != nil { fmt.Printf("failed to fetch server config: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + http.Redirect(w, r, "/error", http.StatusInternalServerError) + return + } + 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 } - } else { - fmt.Printf("getting JWKS from param failed and endpoints config unavailable\n") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - 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) + // forward the JWKS from the authorization server + if jwks == nil { + fmt.Printf("no JWKS was fetched from authorization server\n") + http.Redirect(w, r, "/error", http.StatusInternalServerError) return } - w.Write(jwks) }) r.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { // use refresh token provided to do a refresh token grant refreshToken := r.URL.Query().Get("refresh-token") if refreshToken != "" { - _, err := params.JwtBearerParams.Client.PerformRefreshTokenGrant(client.Provider.Endpoints.Token, refreshToken) + _, err := params.JwtBearerParams.Client.PerformRefreshTokenGrant(provider.Endpoints.Token, refreshToken) if err != nil { fmt.Printf("failed to perform refresh token grant: %v\n", err) http.Redirect(w, r, "/error", http.StatusInternalServerError) @@ -207,8 +176,6 @@ func (s *Server) StartLogin(clients []oauth.Client, params ServerParams) error { return } } 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 var err error accessToken, err = flows.NewClientCredentialsFlow(params.ClientCredentialsEndpoints, params.ClientCredentialsParams) @@ -228,15 +195,10 @@ func (s *Server) StartLogin(clients []oauth.Client, params ServerParams) error { 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) bearerToken, err := client.FetchTokenFromAuthenticationServer( code, + provider.Endpoints.Token, s.State, ) if err != nil { @@ -266,7 +228,6 @@ func (s *Server) StartLogin(clients []oauth.Client, params ServerParams) error { // complete JWT bearer flow to receive access token from authorization server // fmt.Printf("bearer: %v\n", string(bearerToken)) params.JwtBearerParams.IdToken = data["id_token"].(string) - params.JwtBearerParams.Client = client accessToken, err = flows.NewJwtBearerFlow(params.JwtBearerEndpoints, params.JwtBearerParams) if err != nil { fmt.Printf("failed to complete JWT bearer flow: %v\n", err) @@ -333,14 +294,250 @@ func (s *Server) StartLogin(clients []oauth.Client, params ServerParams) error { return s.ListenAndServe() } -func makeButton(url string, text string) string { - // check if we have http:// a - // html := "", text) - html := "%s", url, text) - html += "" - return html +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{} + callback = "" + activeCodes = []string{} + ) + + // 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() } diff --git a/pages/login.html b/pages/login.html index c25f8c3..e55f5cf 100644 --- a/pages/login.html +++ b/pages/login.html @@ -7,7 +7,7 @@

Forgot Username?
- + \ No newline at end of file