client/protocol.aug
OpenID Connect login application · Source and specification
Files in this project
main.augclient/contracts.augclient/endpoints.augclient/export.augclient/login.augclient/logout.augclient/protocol.augclient/session.augclient/views.augcommon/export.augcommon/headers.augcommon/keys.augcommon/settings.augcommon/views.augprovider/authorization.augprovider/contracts.augprovider/credentials.augprovider/discovery.augprovider/export.augprovider/token.augprovider/userinfo.augprovider/views.aug
Code
// aug-spec: "protocol.aug.md" explains this file. Read it before changes; refresh with aug spec.
import SessionError from contracts
import Discovery and IdClaims from provider
import settings from common
import HttpClient from web
import parse from json
import Crypto and GnuTlsCrypto and RsaJwks and rsaJwk and signJwt and importJwk and verifyJwt and JwtError from crypto
/** Accept only a successful JSON response. Redirects remain explicit and are never followed by the transport. */
responseJson(HttpResponse<Bytes> response):
if response.status != 200:
throw SessionError()
match response.headers.get(name="content-type"):
when null:
throw SessionError()
when some contentType:
if not contentType.startsWith(prefix="application/json"):
throw SessionError()
try:
return parse(input=response.body.text())
catch ConversionError error:
throw SessionError()
catch JsonError error:
throw SessionError()
/** Discovery is fetched over HTTP. Every advertised URL is checked against the registered issuer before any credential is sent. */
discover(resolve HttpClient client):
config = settings()
json = responseJson(
response=client.request(
method="GET",
url=config.issuer + "/.well-known/openid-configuration"
)
)
try:
document = json.decode<Discovery>()
if document.issuer != config.issuer or document.authorization_endpoint != config.issuer + "/authorize" or document.token_endpoint != config.issuer + "/token" or document.jwks_uri != config.issuer + "/jwks" or document.userinfo_endpoint != config.issuer + "/userinfo":
throw SessionError()
return document
catch JsonError error:
throw SessionError()
/** Validate the signed ID token using a public key from this issuer's HTTP JWKS, then validate the registered claims and one-use nonce. */
validateIdentity(string token, string nonce, int now, RsaJwks jwks, resolve Crypto crypto):
config = settings()
if jwks.keys.length() != 1:
throw SessionError()
try:
jwk = jwks.keys.get(index=0)
if jwk.kid != "provider-1":
throw SessionError()
publicKey = importJwk(jwk)
claims = verifyJwt(token, publicKey, kid="provider-1", tokenType="JWT").decode<IdClaims>()
if claims.iss != config.issuer or claims.aud != config.clientId or claims.sub.length() == 0 or claims.sub.length() > 255:
throw SessionError()
if claims.exp <= now or claims.iat < now - 300 or claims.iat > now + 30 or claims.exp <= claims.iat or claims.exp > now + 330:
throw SessionError()
if not crypto.equal(left=claims.nonce.bytes(), right=nonce.bytes()):
throw SessionError()
return claims
catch JwtError error:
throw SessionError()
catch JsonError error:
throw SessionError()
catch IndexError error:
throw SessionError()
catch CryptoError error:
throw SessionError()
test validateIdentity:
when "signed_identity_claims":
implement Crypto with GnuTlsCrypto
resolve Crypto to crypto
key = crypto.generateRsa()
publicKey = crypto.publicRsa(key)
jwks = RsaJwks(keys=[rsaJwk(publicKey, kid="provider-1")])
config = settings()
now = 1700000000
expectedNonce = "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"
it "accepts_valid_identity":
claims = IdClaims(
iss=config.issuer,
sub="ada",
aud=config.clientId,
exp=now + 300,
iat=now,
nonce=expectedNonce,
name="Ada"
)
token = signJwt(
key,
claims=Json(value=claims),
kid="provider-1",
tokenType="JWT"
)
identity = validateIdentity(token, nonce=expectedNonce, now, jwks)
assert(condition=identity.sub == "ada")
it "rejects_signed_invalid_claims" for (issuer, audience, subject, issued, expires, nonce) in [(
"https://wrong-issuer.invalid",
config.clientId,
"ada",
now,
now + 300,
expectedNonce
), (config.issuer, "wrong-audience", "ada", now, now + 300, expectedNonce), (config.issuer, config.clientId, "", now, now + 300, expectedNonce), (
config.issuer,
config.clientId,
"ada",
now - 400,
now + 300,
expectedNonce
), (
config.issuer,
config.clientId,
"ada",
now + 100,
now + 300,
expectedNonce
), (config.issuer, config.clientId, "ada", now, now, expectedNonce), (config.issuer, config.clientId, "ada", now, now + 600, expectedNonce), (config.issuer, config.clientId, "ada", now, now + 300, "wrong-nonce")]:
claims = IdClaims(
iss=issuer,
sub=subject,
aud=audience,
exp=expires,
iat=issued,
nonce,
name="Ada"
)
token = signJwt(
key,
claims=Json(value=claims),
kid="provider-1",
tokenType="JWT"
)
try:
validateIdentity(token, nonce=expectedNonce, now, jwks)
assert(condition=false)
catch SessionError error:
assert(condition=true)
it "rejects_token_context" for (kid, tokenType) in [("wrong-key", "JWT"), ("provider-1", "august-session+jwt")]:
claims = IdClaims(
iss=config.issuer,
sub="ada",
aud=config.clientId,
exp=now + 300,
iat=now,
nonce=expectedNonce,
name="Ada"
)
token = signJwt(key, claims=Json(value=claims), kid, tokenType)
try:
validateIdentity(token, nonce=expectedNonce, now, jwks)
assert(condition=false)
catch SessionError error:
assert(condition=true)// aug-spec: "protocol.aug.md" explains this file. Read it before changes; refresh with aug spec.
import SessionError from contracts
import Discovery and IdClaims from provider
import settings from common
import HttpClient from web
import parse from json
import Crypto and GnuTlsCrypto and RsaJwks and rsaJwk and signJwt and importJwk and verifyJwt and JwtError from crypto
/** Accept only a successful JSON response. Redirects remain explicit and are never followed by the transport. */
responseJson(HttpResponse<Bytes> response) {
if response.status != 200 {
throw SessionError()
}
match response.headers.get(name="content-type") {
when null {
throw SessionError()
}
when some contentType {
if not contentType.startsWith(prefix="application/json") {
throw SessionError()
}
}
}
try {
return parse(input=response.body.text())
}
catch ConversionError error {
throw SessionError()
}
catch JsonError error {
throw SessionError()
}
}
/** Discovery is fetched over HTTP. Every advertised URL is checked against the registered issuer before any credential is sent. */
discover(resolve HttpClient client) {
config = settings()
json = responseJson(
response=client.request(
method="GET",
url=config.issuer + "/.well-known/openid-configuration"
)
)
try {
document = json.decode<Discovery>()
if document.issuer != config.issuer or document.authorization_endpoint != config.issuer + "/authorize" or document.token_endpoint != config.issuer + "/token" or document.jwks_uri != config.issuer + "/jwks" or document.userinfo_endpoint != config.issuer + "/userinfo" {
throw SessionError()
}
return document
}
catch JsonError error {
throw SessionError()
}
}
/** Validate the signed ID token using a public key from this issuer's HTTP JWKS, then validate the registered claims and one-use nonce. */
validateIdentity(string token, string nonce, int now, RsaJwks jwks, resolve Crypto crypto) {
config = settings()
if jwks.keys.length() != 1 {
throw SessionError()
}
try {
jwk = jwks.keys.get(index=0)
if jwk.kid != "provider-1" {
throw SessionError()
}
publicKey = importJwk(jwk)
claims = verifyJwt(token, publicKey, kid="provider-1", tokenType="JWT").decode<IdClaims>()
if claims.iss != config.issuer or claims.aud != config.clientId or claims.sub.length() == 0 or claims.sub.length() > 255 {
throw SessionError()
}
if claims.exp <= now or claims.iat < now - 300 or claims.iat > now + 30 or claims.exp <= claims.iat or claims.exp > now + 330 {
throw SessionError()
}
if not crypto.equal(left=claims.nonce.bytes(), right=nonce.bytes()) {
throw SessionError()
}
return claims
}
catch JwtError error {
throw SessionError()
}
catch JsonError error {
throw SessionError()
}
catch IndexError error {
throw SessionError()
}
catch CryptoError error {
throw SessionError()
}
}
test validateIdentity {
when "signed_identity_claims" {
implement Crypto with GnuTlsCrypto
resolve Crypto to crypto
key = crypto.generateRsa()
publicKey = crypto.publicRsa(key)
jwks = RsaJwks(keys=[rsaJwk(publicKey, kid="provider-1")])
config = settings()
now = 1700000000
expectedNonce = "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"
it "accepts_valid_identity" {
claims = IdClaims(
iss=config.issuer,
sub="ada",
aud=config.clientId,
exp=now + 300,
iat=now,
nonce=expectedNonce,
name="Ada"
)
token = signJwt(
key,
claims=Json(value=claims),
kid="provider-1",
tokenType="JWT"
)
identity = validateIdentity(token, nonce=expectedNonce, now, jwks)
assert(condition=identity.sub == "ada")
}
it "rejects_signed_invalid_claims" for (issuer, audience, subject, issued, expires, nonce) in [(
"https://wrong-issuer.invalid",
config.clientId,
"ada",
now,
now + 300,
expectedNonce
), (config.issuer, "wrong-audience", "ada", now, now + 300, expectedNonce), (config.issuer, config.clientId, "", now, now + 300, expectedNonce), (
config.issuer,
config.clientId,
"ada",
now - 400,
now + 300,
expectedNonce
), (
config.issuer,
config.clientId,
"ada",
now + 100,
now + 300,
expectedNonce
), (config.issuer, config.clientId, "ada", now, now, expectedNonce), (config.issuer, config.clientId, "ada", now, now + 600, expectedNonce), (config.issuer, config.clientId, "ada", now, now + 300, "wrong-nonce")] {
claims = IdClaims(
iss=issuer,
sub=subject,
aud=audience,
exp=expires,
iat=issued,
nonce,
name="Ada"
)
token = signJwt(
key,
claims=Json(value=claims),
kid="provider-1",
tokenType="JWT"
)
try {
validateIdentity(token, nonce=expectedNonce, now, jwks)
assert(condition=false)
}
catch SessionError error {
assert(condition=true)
}
}
it "rejects_token_context" for (kid, tokenType) in [("wrong-key", "JWT"), ("provider-1", "august-session+jwt")] {
claims = IdClaims(
iss=config.issuer,
sub="ada",
aud=config.clientId,
exp=now + 300,
iat=now,
nonce=expectedNonce,
name="Ada"
)
token = signJwt(key, claims=Json(value=claims), kid, tokenType)
try {
validateIdentity(token, nonce=expectedNonce, now, jwks)
assert(condition=false)
}
catch SessionError error {
assert(condition=true)
}
}
}
}Compiled specification
responseJson · source
Accept only a successful JSON response. Redirects remain explicit and are never followed by the transport. It takes response as HttpResponse<Bytes>. Failures can raise SessionError.
It checks that response.status equals 200. It raises a SessionError at the first failed check. It obtains response.headers.get with name "content-type". If no value is found, it raises a SessionError. source
The non-null result becomes contentType. It checks that contentType.startsWith with prefix "application/json" returns true. It raises a SessionError at the first failed check. source
It tries to return parse with input from response.body.text. If this work raises ConversionError, it raises a SessionError. If this work raises JsonError, it raises a SessionError. source
Checked interface
responseJson(HttpResponse<Bytes> response) returns Json unless SessionErrorIt takes response as HttpResponse<Bytes>. Failures can raise SessionError.
discover · source
Discovery is fetched over HTTP. Every advertised URL is checked against the registered issuer before any credential is sent. It gets client (HttpClient) from dependency injection.
It can call HttpClient.request. Failures can raise HttpError and SessionError.
It gets config from settings. It sets json to responseJson with response from client.request with method "GET" and url from the text {config.issuer}/.well-known/openid-configuration. It sets document to json.decode for Discovery. source
It checks that document.issuer equals config.issuer and document.authorization_endpoint equals the text {config.issuer}/authorize and document.token_endpoint equals the text {config.issuer}/token and document.jwks_uri equals the text {config.issuer}/jwks and document.userinfo_endpoint equals the text {config.issuer}/userinfo. It raises a SessionError at the first failed check. It returns document. If this work raises JsonError, it raises a SessionError. source
Checked interface
discover(resolve HttpClient client) returns Discovery unless HttpError and SessionError uses HttpClient.requestIt gets client (HttpClient) from dependency injection. It can call HttpClient.request. Failures can raise HttpError and SessionError.
validateIdentity · source
Validate the signed ID token using a public key from this issuer's HTTP JWKS, then validate the registered claims and one-use nonce. It takes token and nonce as strings, now as an integer, and jwks as RsaJwks. It gets crypto (Crypto) from dependency injection.
It can call Crypto.equal, Crypto.decodeBase64url, Crypto.importRsa, and Crypto.verifyRsa. Failures can raise SessionError.
It gets config from settings. It checks that the number of elements in jwks.keys equals 1. It raises a SessionError at the first failed check. It sets jwk to the item at index 0 in jwks.keys. source
It checks that jwk.kid equals "provider-1". It raises a SessionError at the first failed check. It sets publicKey to importJwk with jwk using injected crypto. It sets claims to decode on verifyJwt with token, publicKey, kid "provider-1", and tokenType "JWT" using injected crypto for IdClaims. source
It checks that claims.iss equals config.issuer and claims.aud equals config.clientId and the byte length of claims.sub does not equal 0 and the byte length of claims.sub is at most 255 and claims.exp is greater than now and claims.iat is at least (now minus 300) and claims.iat is at most (now plus 30) and claims.exp is greater than claims.iat and claims.exp is at most (now plus 330). It raises a SessionError at the first failed check. It checks that crypto.equal with left from the UTF-8 bytes of claims.nonce and right from the UTF-8 bytes of nonce returns true. It raises a SessionError at the first failed check. source
It returns claims. If this work raises JwtError, it raises a SessionError. If this work raises JsonError, it raises a SessionError. If this work raises IndexError, it raises a SessionError. source
If this work raises CryptoError, it raises a SessionError. source
Checked interface
validateIdentity(string token, string nonce, int now, RsaJwks jwks, resolve Crypto crypto) returns IdClaims unless SessionError uses Crypto.equal, Crypto.decodeBase64url, Crypto.importRsa, Crypto.verifyRsaIt takes token and nonce as strings, now as an integer, and jwks as RsaJwks. It gets crypto (Crypto) from dependency injection. It can call Crypto.equal, Crypto.decodeBase64url, Crypto.importRsa, and Crypto.verifyRsa. Failures can raise SessionError.
test validateIdentity · source
Tests validateIdentity. Each case gets fresh setup and dependencies.
signed_identity_claims
Setup for each case: Crypto is provided by GnuTlsCrypto. Stateless instances are reused; stateful instances are created for each resolve. It sets crypto to the instance provided for Crypto. It sets key to crypto.generateRsa. source
It sets publicKey to crypto.publicRsa with key. It sets jwks to a RsaJwks with keys from a list containing rsaJwk with publicKey and kid "provider-1" using injected Crypto for crypto. It gets config from settings. It sets now to 1700000000. source
It sets expectedNonce to "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn". source
It sets claims to an IdClaims with iss from config.issuer, sub "ada", aud from config.clientId, exp from now plus 300, iat from now, nonce from expectedNonce, and name "Ada". It sets token to signJwt with key, claims from a Json with value from claims, kid "provider-1", and tokenType "JWT" using injected Crypto for crypto. It sets identity to validateIdentity with token, nonce from expectedNonce, now, and jwks using injected Crypto for crypto. The test requires identity.sub equals "ada". source
Run once for each row of a tuple containing "https://wrong-issuer.invalid", config.clientId, "ada", now, now plus 300, expectedNonce; a tuple containing config.issuer, "wrong-audience", "ada", now, now plus 300, expectedNonce; a tuple containing config.issuer, config.clientId, "", now, now plus 300, expectedNonce; a tuple containing config.issuer, config.clientId, "ada", now minus 400, now plus 300, expectedNonce; a tuple containing config.issuer, config.clientId, "ada", now plus 100, now plus 300, expectedNonce; a tuple containing config.issuer, config.clientId, "ada", now, now, expectedNonce; a tuple containing config.issuer, config.clientId, "ada", now, now plus 600, expectedNonce; a tuple containing config.issuer, config.clientId, "ada", now, now plus 300, "wrong-nonce". Bind row positions to issuer, audience, subject, issued, expires, nonce.
It sets claims to an IdClaims with iss from issuer, sub from subject, aud from audience, exp from expires, iat from issued, nonce, and name "Ada". It sets token to signJwt with key, claims from a Json with value from claims, kid "provider-1", and tokenType "JWT" using injected Crypto for crypto. It calls validateIdentity with token, nonce from expectedNonce, now, and jwks using injected Crypto for crypto. The test requires false is true. source
If this work raises SessionError, it the test requires true is true. source
Run once for each row of a tuple containing "wrong-key", "JWT"; a tuple containing "provider-1", "august-session+jwt". Bind row positions to kid, tokenType.
It sets claims to an IdClaims with iss from config.issuer, sub "ada", aud from config.clientId, exp from now plus 300, iat from now, nonce from expectedNonce, and name "Ada". It sets token to signJwt with key, claims from a Json with value from claims, kid, and tokenType using injected Crypto for crypto. It calls validateIdentity with token, nonce from expectedNonce, now, and jwks using injected Crypto for crypto. The test requires false is true. source
If this work raises SessionError, it the test requires true is true. source
Dependencies
It uses parse from json. It uses HttpClient (request) from web. It uses Crypto (decodeBase64url, equal, generateRsa, importRsa, publicRsa, and verifyRsa), GnuTlsCrypto, JwtError, RsaJwks (keys), importJwk, rsaJwk, signJwt, and verifyJwt from crypto. It uses RsaJwk (kid) and Settings (clientId and issuer).
It uses SessionError from contracts. It uses settings from common. It uses IdClaims (aud, exp, iat, iss, nonce, and sub) and Discovery (authorization_endpoint, issuer, jwks_uri, token_endpoint, and userinfo_endpoint) from provider.
Built-in operations follow the language reference.