This page contains a POC to demonstrate how a Native App can use Phase 1.5 to authenticate a user and extract basic information of interest.
The Authorization flow for a Native App looks like:
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+ | User Device | | | | +--------------------------+ | (5) Authorization +---------------+ | | | | Code | | | | Client App |---------------------->| Token | | | |<----------------------| Endpoint | | +--------------------------+ | (6) Access Token, | | | | ^ | Refresh Token +---------------+ | | | | | | | | | | (1) | (4) | | | Authorizat- | Authoriza- | | | ion Request | tion Code | | | | | | | | | | v | | | +---------------------------+ | (2) Authorization +---------------+ | | | | Request | | | | Browser |--------------------->| Authorization | | | |<---------------------| Endpoint | | +---------------------------+ | (3) Authorization | | | | Code +---------------+ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+ Figure 1: Native App Authorization via an External User-Agent
Here we leverage the Authorization Endpoint and Token Endpoint, both of which are
https://qa-token.auth.topcon.com
Production: https://token.auth.topcon.com
Accordingly the recommended implementation is:
${PORT}
for the browser to call back;code verifier
for PKCE and compute the associated code_challenge
;GET |
| ||||||||||||||||
PARAMETERS |
|
The token server will then manage the authentication process and (4) call back to your application with a frame
{
"code" : "...."
}
Your application proceeds by
(5) sending a request to the token server
POST |
| ||||||||||||||||
PARAMETERS |
| ||||||||||||||||
RESPONSE | (6) An object with the following fields
|
The POC is written in golang and tested on Linux Ubuntu 22.04.3 LTS
The application itself uses the OAuth2 code
flow to
To achieve that, you provide the following values in the environment:
The code then
func RunRedirectServer
) starts a server listening on the redirect portfunc OpenAuthorizationEndpoint)
arranges for the PingFederate Authorization Code flow to log in the user via system browser and call back into that server with a valid code
func GetTokenByAuthorizationCode
) exchanges this code for a tokenfunc DisplayGetTokenResponse
) prints the token and associated informationWe also demonstrate
CodeChallengeVerifier
value is created dynamically, and its hash is created by func codeChallenge
and then used as the code_challenge
value in func OpenAuthorizationEndpointuse
forceBPChoice
to true
will force the user to select a business partner even if they already have one set.package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/dchest/uniuri"
)
var (
// Environment variables we expect:
clientID = os.Getenv("CLIENT_ID")
clientSecret = os.Getenv("CLIENT_SECRET")
redirectPortString = os.Getenv("REDIRECT_PORT")
CodeChallengeVerifier = uniuri.NewLen(50) // must be between 43 and 128 characters
redirectPort = 3434 // default port if redirectPortString is not a valid int
redirectURI = "" // built from the redirectPort
doneCh = make(chan struct{}) // closed when the server is no longer needed.
forceBPChoice = false // make sure the user always chooses a new business partner
)
func main() {
// make sure all the required settings make sense.
if rp, err := strconv.Atoi(redirectPortString); err == nil {
redirectPort = rp
}
redirectURI = fmt.Sprintf("http://localhost:%d", redirectPort)
notice("redirectURI=%s", redirectURI)
OpenAuthorizationEndpoint(clientID, redirectURI)
RunRedirectServer(redirectPort)
}
func OpenAuthorizationEndpoint(clientID string, redirectURI string) {
authorizeEndpoint := &url.URL{
Scheme: "https",
Host: "qa-token.auth.topcon.com",
Path: "/as/authorization.oauth2",
}
queryParams := authorizeEndpoint.Query()
queryParams.Add("client_id", clientID)
queryParams.Add("redirect_uri", redirectURI)
queryParams.Add("response_type", "code")
queryParams.Add("scope", "openid profile email")
queryParams.Add("code_challenge", codeChallenge(CodeChallengeVerifier))
queryParams.Add("code_challenge_method", "S256")
if forceBPChoice {
queryParams.Add("switch", "bp")
}
authorizeEndpoint.RawQuery = queryParams.Encode()
notice("authorizeEndpoint=%s", authorizeEndpoint.String())
cmd := exec.Command("xdg-open", authorizeEndpoint.String())
err := cmd.Run()
panicOnError(err)
}
func RunRedirectServer(redirectPort int) {
server := &http.Server{
Addr: fmt.Sprintf("localhost:%d", redirectPort),
Handler: http.HandlerFunc(ServeHTTP),
}
go func() {
err := server.ListenAndServe()
switch err {
case http.ErrServerClosed:
notice("server closed")
default:
panicOnError(err)
}
}()
defer func() {
ctx, cancelFn := context.WithCancel(context.Background())
cancelFn()
server.Shutdown(ctx)
}()
<-doneCh
}
func ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer func() {
close(doneCh)
}()
w.Write([]byte("You can close this window"))
w.(http.Flusher).Flush()
m, err := url.ParseQuery(r.URL.RawQuery)
panicOnError(err)
code := m.Get("code")
notice("got code [%s] from ping... ", code)
getTokenByAuthorizationCodeOutput := GetTokenByAuthorizationCode(&GetTokenByAuthorizationCodeInput{
ClientID: clientID,
ClientSecret: clientSecret,
Code: code,
RedirectURI: redirectURI,
})
DisplayGetTokenResponse(getTokenByAuthorizationCodeOutput)
}
type GetTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
}
func DisplayGetTokenResponse(response *GetTokenResponse) {
prettyJSON, err := json.MarshalIndent(response, "", " ")
panicOnError(err)
fmt.Println("Authorization data received from CIAM:")
fmt.Println(string(prettyJSON))
}
func codeChallenge(verifier string) string {
hasher := sha256.New()
hasher.Write([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
}
type GetTokenByAuthorizationCodeInput struct {
ClientID string
ClientSecret string
Code string
RedirectURI string
}
func GetTokenByAuthorizationCode(input *GetTokenByAuthorizationCodeInput) (output *GetTokenResponse) {
values := make(url.Values)
values.Set("grant_type", "authorization_code")
values.Set("code", input.Code)
values.Set("code_verifier", CodeChallengeVerifier)
values.Set("redirect_uri", input.RedirectURI)
formData := values.Encode()
output = GetToken(&GetTokenInput{
ClientID: input.ClientID,
ClientSecret: input.ClientSecret,
Body: strings.NewReader(formData),
})
return
}
type GetTokenInput struct {
ClientID string
ClientSecret string
Body io.Reader
}
func GetToken(input *GetTokenInput) (output *GetTokenResponse) {
tokenEndpoint := &url.URL{
Scheme: "https",
Host: "qa-token.auth.topcon.com",
Path: "/as/token.oauth2",
}
notice("sending token request to %s", tokenEndpoint.String())
request, err := http.NewRequest(http.MethodPost, tokenEndpoint.String(), input.Body)
panicOnError(err)
request.SetBasicAuth(input.ClientID, input.ClientSecret)
request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
client := http.DefaultClient
response, err := client.Do(request)
panicOnError(err)
defer func() {
response.Body.Close()
}()
if response.StatusCode != http.StatusOK {
data, err := ioutil.ReadAll(response.Body)
panicOnError(err)
panic(fmt.Sprintf("failed to get token %d: %s", response.StatusCode, data))
}
err = json.NewDecoder(response.Body).Decode(&output)
panicOnError(err)
return
}
// -----------------------------------------------------------------------------
// We have some utilities that do this better, but for example code we hard-wire
// see tps-git.topcon.com/goutil/fatal-go
func panicOnError(err error) {
if err == nil {
return
}
panic(err.Error())
}
// see tps-git.topcon.com/goutil/logger-go
func notice(format string, a ...interface{}) {
logIt(2, "NOTICE", fmt.Sprintf(format, a...))
}
func logIt(depth int, level string, text string) {
dir, file, line := "n/a", "n/a", -1
_, fileName, fileLine, ok := runtime.Caller(depth)
if ok {
dir, file, line = filepath.Base(filepath.Dir(fileName)), filepath.Base(fileName), fileLine
}
log.Printf("%-6s: %s (%s/%s:%d)", level, strings.TrimSpace(text), dir, file, line)
}