Skip to content

Commit

Permalink
feat: refine docs and error messages (#27)
Browse files Browse the repository at this point in the history
* docs: refine docs for all functions

* feat: export type for SgidClient init params

* docs: document default for scope param

* feat: improve error messages and extract into file

* test: update test error messages
  • Loading branch information
mantariksh authored May 2, 2023
1 parent 1111f49 commit c7dcf95
Show file tree
Hide file tree
Showing 3 changed files with 72 additions and 28 deletions.
15 changes: 15 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// authorization URL error, also used in callback
export const MISSING_REDIRECT_URI_ERROR =
'No redirect URI registered with this client. You must either specify a valid redirect URI in the SgidClient constructor, or pass it to the authorizationUrl and callback functions.'

// callback errors
export const NO_SUB_ERROR = 'Authorization server did not return the sub claim'
export const NO_ACCESS_TOKEN_ERROR =
'Authorization server did not return an access token'

// userinfo errors
export const PRIVATE_KEY_IMPORT_ERROR =
'Failed to import private key. Check that privateKey is a valid PKCS1 or PKCS8 key.'
export const DECRYPT_BLOCK_KEY_ERROR =
'Unable to decrypt or import payload key. Check that you used the correct private key.'
export const DECRYPT_PAYLOAD_ERROR = 'Unable to decrypt payload'
81 changes: 55 additions & 26 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,50 @@ import {
ResponseType,
} from 'openid-client'

import * as Errors from './error'
import { convertPkcs1ToPkcs8 } from './util'

const SGID_SIGNING_ALG = 'RS256'
const SGID_SUPPORTED_FLOWS: ResponseType[] = ['code']
const SGID_AUTH_METHOD: ClientAuthMethod = 'client_secret_post'

// Exported for RPs' convenience, e.g. if they want to
// write a function to construct the params
export type SgidClientParams = {
clientId: string
clientSecret: string
privateKey: string
redirectUri?: string
hostname?: string
apiVersion?: number
}

export class SgidClient {
private privateKey: string

private sgID: Client

/**
* Initialises an SgidClient instance.
* @param params Constructor arguments
* @param params.clientId Client ID provided during client registration
* @param params.clientSecret Client secret provided during client registration
* @param params.privateKey Client private key provided during client registration
* @param params.redirectUri Redirection URI for user to return to your application
* after login. If not provided in the constructor, this must be provided to the
* authorizationUrl and callback functions.
* @param params.hostname Hostname of OpenID provider (sgID). Defaults to
* https://api.id.gov.sg.
* @param params.apiVersion sgID API version to use. Defaults to 1.
*/
constructor({
clientId,
clientSecret,
privateKey,
redirectUri,
hostname = 'https://api.id.gov.sg',
apiVersion = 1,
}: {
clientId: string
clientSecret: string
privateKey: string
redirectUri?: string
hostname?: string
apiVersion?: number
}) {
}: SgidClientParams) {
// TODO: Discover sgID issuer metadata via .well-known endpoint
const { Client } = new Issuer({
issuer: new URL(hostname).origin,
Expand Down Expand Up @@ -62,12 +80,17 @@ export class SgidClient {
}

/**
* Generates authorization url for sgID OIDC flow
* @param state A random string to prevent CSRF
* @param scopes Array or space-separated scopes, must include openid
* @param nonce Specify null if no nonce
* @param redirectUri The redirect URI used in the authorization request, defaults to the one registered with the client
* @returns
* Generates authorization url to redirect end-user to sgID login page.
* @param state A string which will be passed back to your application once the end-user
* logs in. You should use this to prevent cross-site request forgery attacks (see
* https://www.rfc-editor.org/rfc/rfc6749#section-10.12). You can also use this to
* track per-request state.
* @param scopes Array or space-separated scopes. 'openid' must be provided as a scope.
* Defaults to 'myinfo.nric_number openid'.
* @param nonce Unique nonce for this request. If this param is undefined, a nonce is generated
* and returned. To prevent this behaviour, specify null for this param.
* @param redirectUri The redirect URI used in the authorization request. Defaults to the one
* passed to the SgidClient constructor.
*/
authorizationUrl(
state: string,
Expand All @@ -94,17 +117,20 @@ export class SgidClient {
this.sgID.metadata.redirect_uris.length === 0
) {
// eslint-disable-next-line typesafe/no-throw-sync-func
throw new Error('No redirect URI registered with this client')
throw new Error(Errors.MISSING_REDIRECT_URI_ERROR)
}
return this.sgID.metadata.redirect_uris[0]
}

/**
* Callback handler for sgID OIDC flow
* Exchanges authorization code for access token.
* @param code The authorization code received from the authorization server
* @param nonce Specify null if no nonce
* @param redirectUri The redirect URI used in the authorization request, defaults to the one registered with the client
* @returns The sub of the user and access token
* @param nonce Nonce passed to authorizationUrl for this request. Specify null
* if no nonce was passed to authorizationUrl.
* @param redirectUri The redirect URI used in the authorization request. Defaults to the one
* passed to the SgidClient constructor.
* @returns The sub (subject identifier claim) of the user and access token. The subject
* identifier claim is the end-user's unique ID.
*/
async callback(
code: string,
Expand All @@ -118,16 +144,19 @@ export class SgidClient {
)
const { sub } = tokenSet.claims()
const { access_token: accessToken } = tokenSet
if (!sub || !accessToken) {
throw new Error('Missing sub claim or access token')
if (!sub) {
throw new Error(Errors.NO_SUB_ERROR)
}
if (!accessToken) {
throw new Error(Errors.NO_ACCESS_TOKEN_ERROR)
}
return { sub, accessToken }
}

/**
* Retrieve verified user info and decrypt with client's private key
* Retrieves verified user info and decrypts it with your private key.
* @param accessToken The access token returned in the callback function
* @returns The sub of the user and data
* @returns The sub of the end-user and the end-user's verified data
*/
async userinfo(
accessToken: string,
Expand Down Expand Up @@ -165,7 +194,7 @@ export class SgidClient {
// Import client private key in PKCS8 format
privateKeyJwk = await importPKCS8(this.privateKey, 'RSA-OAEP-256')
} catch (e) {
throw new Error('Failed to import private key')
throw new Error(Errors.PRIVATE_KEY_IMPORT_ERROR)
}

// Decrypt key to get plaintext symmetric key
Expand All @@ -176,7 +205,7 @@ export class SgidClient {
)
payloadJwk = await importJWK(JSON.parse(decryptedKey))
} catch (e) {
throw new Error('Unable to decrypt or import payload key')
throw new Error(Errors.DECRYPT_BLOCK_KEY_ERROR)
}

// Decrypt each jwe in body
Expand All @@ -190,7 +219,7 @@ export class SgidClient {
result[field] = decryptedValue
}
} catch (e) {
throw new Error('Unable to decrypt payload')
throw new Error(Errors.DECRYPT_PAYLOAD_ERROR)
}
return result
}
Expand Down
4 changes: 2 additions & 2 deletions test/SgidClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,15 +259,15 @@ describe('SgidClient', () => {
server.use(tokenHandlerNoToken)

await expect(client.callback(MOCK_AUTH_CODE)).rejects.toThrow(
'Missing sub claim or access token',
'Authorization server did not return an access token',
)
})

it('should throw when sub is empty', async () => {
server.use(tokenHandlerNoSub)

await expect(client.callback(MOCK_AUTH_CODE)).rejects.toThrow(
'Missing sub claim or access token',
'Authorization server did not return the sub claim',
)
})
})
Expand Down

0 comments on commit c7dcf95

Please sign in to comment.