TSDN
CIAMBest PracticesNative App Example

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 

Accordingly the recommended implementation is:

  • start a local webserver on a known port ${PORT} for the browser to call back;
  • choose a code verifier for PKCE and compute the associated code_challenge;
  • (1) send an Authorization Request 
GET

${TOKEN_URL}/as/authorization.oauth2

PARAMETERS
Keydescription
client_idyour PingFederate client ID
code_challengethe computed PKCE code challenge value
code_challenge_methodS256
redirect_urihttp://localhost:${PORT}
response_typecode
scope

openid topcon.ciam.phase.1.5

access_typeoffline

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

    ${TOKEN_URL}/as/token.oauth2

    PARAMETERS
    Keydescription
    client_idyour PingFederate client ID
    code_verifierthe computed PKCE code challenge value
    codethe code value from the token server
    grant_typeurn:pingidentity.com:oauth2:grant_type:validate_bearer
    redirect_urlhttp://localhost:${PORT}
    scopeopenid topcon.ciam.phase.1.5
    access_typeoffline
    RESPONSE

    (6) An object with the following fields

    Keydescription
    access_tokenthe access token to use (this is what you actually want)
    expires_innumber of seconds the token is valid for
    id_tokena token that provides identity, but not authorization
    refresh_tokena token you can use to obtain new access_tokens when the existing expires
    token_typeBearer
    scopeopenid topcon.ciam.phase.1.5


  • Your application now has an access token it can use for access to services that use CIAM.



Working Example

The POC is written in golang and tested on Linux Ubuntu 22.04.3 LTS

The application itself uses the OAuth2 code  flow to

  • log in through QA Ping Federate;
  • get a new-style token for TRAPI;
  • print the token

To achieve that, you provide the following values in the environment:

  • CLIENT_ID: your PingFederate client ID
  • CLIENT_SECRET: your PingFederate client secret
  • REDIRECT_PORT: a free port on your localhost.

The code then

  • (in func RunRedirectServer) starts a server listening on the redirect port
  • (in func 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 
  • (in func GetTokenByAuthorizationCode) exchanges this code for a token
  • (in func DisplayGetTokenResponse) prints the token and associated information

We also demonstrate

  • use of PKCE: the  CodeChallengeVerifier  value is created dynamically, and its hash is created by func codeChallenge  and then used as the code_challenge  value in func OpenAuthorizationEndpointuse 
  • switching business partner: setting 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 := &amp;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 := &amp;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)
	}()

	&lt;-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(&amp;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(&amp;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 := &amp;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(&amp;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)
}