client/login.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: "login.aug.md" explains this file. Read it before changes; refresh with aug spec.
import LoginTransaction and SessionClaims and SessionError from contracts
import discover and responseJson and validateIdentity from protocol
import TokenResponse and UserInfo from provider
import settings and SigningKeys and KeyError and securityHeaders and withCookie from common
import Crypto and RsaJwks and signJwt and JwtError from crypto
import Clock from time
import HttpClient and urlEncode from web
import ExpiringStore and StoreFull from memory
/** Start a browser-bound, short-lived transaction. The PKCE verifier stays on the server. */
endpoint GET "/login/start" as startLogin(resolve Crypto crypto, resolve Clock clock, resolve HttpClient client, resolve ExpiringStore<LoginTransaction> transactions) unless SessionError with status 502 and CryptoError with status 503 and TimeError with status 503 and StoreFull with status 503 and HttpError:
config = settings()
document = discover()
browser = crypto.random(size=32).base64url()
state = crypto.random(size=32).base64url()
nonce = crypto.random(size=32).base64url()
verifier = crypto.random(size=32).base64url()
transaction = LoginTransaction(state, nonce, verifier, expires=clock.now() + 300)
transactions.put(
key=browser,
value=transaction,
expires=transaction.expires,
now=clock.now()
)
challenge = crypto.sha256(input=verifier.bytes()).base64url()
location = document.authorization_endpoint + "?response_type=code&client_id=" + urlEncode(input=config.clientId) + "&redirect_uri=" + urlEncode(input=config.callback) + "&scope=openid%20profile&state=" + urlEncode(input=state) + "&nonce=" + urlEncode(input=nonce) + "&code_challenge=" + urlEncode(input=challenge) + "&code_challenge_method=S256"
headers = withCookie(
headers=securityHeaders().with(name="location", value=location),
name="aug_login",
value=browser,
path="/login",
maxAge=300,
secure=config.secureCookies
)
return HttpResponse(
body=<p>Opening the identity provider.</p>,
status=303,
headers=headers
)
/** Exchange a one-use code over HTTP, verify the provider JWT/JWKS and UserInfo subject, then issue a distinct app-session JWT. */
endpoint GET "/login/callback" as loginCallback(string code from query, string state from query, optional string browser from cookie "aug_login", resolve Crypto crypto, resolve Clock clock, resolve HttpClient client, resolve SigningKeys keys, resolve ExpiringStore<LoginTransaction> transactions, resolve ExpiringStore<SessionClaims> sessions) unless SessionError with status 400 and CryptoError with status 503 and TimeError with status 503 and KeyError and StoreFull with status 503 and JwtError and JsonError and HttpError:
if not code.isToken(min=43, max=43) or not state.isToken(min=43, max=43):
throw SessionError()
match browser:
when null:
throw SessionError()
when some secret:
match transactions.take(key=secret, now=clock.now()):
when null:
throw SessionError()
when some transaction:
if not crypto.equal(
left=transaction.state.bytes(),
right=state.bytes()
):
throw SessionError()
config = settings()
document = discover()
body = "grant_type=authorization_code&code=" + urlEncode(input=code) + "&redirect_uri=" + urlEncode(input=config.callback) + "&client_id=" + urlEncode(input=config.clientId) + "&code_verifier=" + urlEncode(input=transaction.verifier)
headers = Headers().with(
name="content-type",
value="application/x-www-form-urlencoded"
)
tokens = responseJson(
response=client.request(
method="POST",
url=document.token_endpoint,
headers,
body=body.bytes()
)
).decode<TokenResponse>()
if tokens.token_type != "Bearer" or not tokens.access_token.isToken(min=43, max=43) or tokens.expires_in <= 0:
throw SessionError()
jwks = responseJson(
response=client.request(method="GET", url=document.jwks_uri)
).decode<RsaJwks>()
identity = validateIdentity(
token=tokens.id_token,
nonce=transaction.nonce,
now=clock.now(),
jwks
)
authHeaders = Headers().with(
name="authorization",
value="Bearer " + tokens.access_token
)
user = responseJson(
response=client.request(
method="GET",
url=document.userinfo_endpoint,
headers=authHeaders
)
).decode<UserInfo>()
if user.sub != identity.sub:
throw SessionError()
now = clock.now()
session = SessionClaims(
iss=config.baseUrl + "/app",
sub=identity.sub,
aud="august-app",
exp=now + config.sessionSeconds,
iat=now,
jti=crypto.random(size=32).base64url(),
csrf=crypto.random(size=32).base64url(),
name=user.name
)
jwt = signJwt(
key=keys.session(),
claims=Json(value=session),
kid="session-1",
tokenType="august-session+jwt"
)
sessions.put(
key=session.jti,
value=session,
expires=session.exp,
now=now
)
responseHeaders = withCookie(
headers=securityHeaders().with(name="location", value="/"),
name="aug_session",
value=jwt,
path="/",
maxAge=config.sessionSeconds,
secure=config.secureCookies
)
responseHeaders = withCookie(
headers=responseHeaders,
name="aug_login",
value="",
path="/login",
maxAge=0,
secure=config.secureCookies
)
return HttpResponse(
body=<p>Signed in.</p>,
status=303,
headers=responseHeaders
)// aug-spec: "login.aug.md" explains this file. Read it before changes; refresh with aug spec.
import LoginTransaction and SessionClaims and SessionError from contracts
import discover and responseJson and validateIdentity from protocol
import TokenResponse and UserInfo from provider
import settings and SigningKeys and KeyError and securityHeaders and withCookie from common
import Crypto and RsaJwks and signJwt and JwtError from crypto
import Clock from time
import HttpClient and urlEncode from web
import ExpiringStore and StoreFull from memory
/** Start a browser-bound, short-lived transaction. The PKCE verifier stays on the server. */
endpoint GET "/login/start" as startLogin(resolve Crypto crypto, resolve Clock clock, resolve HttpClient client, resolve ExpiringStore<LoginTransaction> transactions) unless SessionError with status 502 and CryptoError with status 503 and TimeError with status 503 and StoreFull with status 503 and HttpError {
config = settings()
document = discover()
browser = crypto.random(size=32).base64url()
state = crypto.random(size=32).base64url()
nonce = crypto.random(size=32).base64url()
verifier = crypto.random(size=32).base64url()
transaction = LoginTransaction(state, nonce, verifier, expires=clock.now() + 300)
transactions.put(
key=browser,
value=transaction,
expires=transaction.expires,
now=clock.now()
)
challenge = crypto.sha256(input=verifier.bytes()).base64url()
location = document.authorization_endpoint + "?response_type=code&client_id=" + urlEncode(input=config.clientId) + "&redirect_uri=" + urlEncode(input=config.callback) + "&scope=openid%20profile&state=" + urlEncode(input=state) + "&nonce=" + urlEncode(input=nonce) + "&code_challenge=" + urlEncode(input=challenge) + "&code_challenge_method=S256"
headers = withCookie(
headers=securityHeaders().with(name="location", value=location),
name="aug_login",
value=browser,
path="/login",
maxAge=300,
secure=config.secureCookies
)
return HttpResponse(
body=<p>Opening the identity provider.</p>,
status=303,
headers=headers
)
}
/** Exchange a one-use code over HTTP, verify the provider JWT/JWKS and UserInfo subject, then issue a distinct app-session JWT. */
endpoint GET "/login/callback" as loginCallback(string code from query, string state from query, optional string browser from cookie "aug_login", resolve Crypto crypto, resolve Clock clock, resolve HttpClient client, resolve SigningKeys keys, resolve ExpiringStore<LoginTransaction> transactions, resolve ExpiringStore<SessionClaims> sessions) unless SessionError with status 400 and CryptoError with status 503 and TimeError with status 503 and KeyError and StoreFull with status 503 and JwtError and JsonError and HttpError {
if not code.isToken(min=43, max=43) or not state.isToken(min=43, max=43) {
throw SessionError()
}
match browser {
when null {
throw SessionError()
}
when some secret {
match transactions.take(key=secret, now=clock.now()) {
when null {
throw SessionError()
}
when some transaction {
if not crypto.equal(
left=transaction.state.bytes(),
right=state.bytes()
) {
throw SessionError()
}
config = settings()
document = discover()
body = "grant_type=authorization_code&code=" + urlEncode(input=code) + "&redirect_uri=" + urlEncode(input=config.callback) + "&client_id=" + urlEncode(input=config.clientId) + "&code_verifier=" + urlEncode(input=transaction.verifier)
headers = Headers().with(
name="content-type",
value="application/x-www-form-urlencoded"
)
tokens = responseJson(
response=client.request(
method="POST",
url=document.token_endpoint,
headers,
body=body.bytes()
)
).decode<TokenResponse>()
if tokens.token_type != "Bearer" or not tokens.access_token.isToken(min=43, max=43) or tokens.expires_in <= 0 {
throw SessionError()
}
jwks = responseJson(
response=client.request(method="GET", url=document.jwks_uri)
).decode<RsaJwks>()
identity = validateIdentity(
token=tokens.id_token,
nonce=transaction.nonce,
now=clock.now(),
jwks
)
authHeaders = Headers().with(
name="authorization",
value="Bearer " + tokens.access_token
)
user = responseJson(
response=client.request(
method="GET",
url=document.userinfo_endpoint,
headers=authHeaders
)
).decode<UserInfo>()
if user.sub != identity.sub {
throw SessionError()
}
now = clock.now()
session = SessionClaims(
iss=config.baseUrl + "/app",
sub=identity.sub,
aud="august-app",
exp=now + config.sessionSeconds,
iat=now,
jti=crypto.random(size=32).base64url(),
csrf=crypto.random(size=32).base64url(),
name=user.name
)
jwt = signJwt(
key=keys.session(),
claims=Json(value=session),
kid="session-1",
tokenType="august-session+jwt"
)
sessions.put(
key=session.jti,
value=session,
expires=session.exp,
now=now
)
responseHeaders = withCookie(
headers=securityHeaders().with(name="location", value="/"),
name="aug_session",
value=jwt,
path="/",
maxAge=config.sessionSeconds,
secure=config.secureCookies
)
responseHeaders = withCookie(
headers=responseHeaders,
name="aug_login",
value="",
path="/login",
maxAge=0,
secure=config.secureCookies
)
return HttpResponse(
body=<p>Signed in.</p>,
status=303,
headers=responseHeaders
)
}
}
}
}
}Compiled specification
Plain handler results default to HTTP 200 unless another status is declared. HttpResponse values choose their own status. Unhandled request failures return HTTP 500 and cancel the request tasks.
startLogin · source
startLogin handles GET /login/start. Start a browser-bound, short-lived transaction. The PKCE verifier stays on the server. It gets crypto (Crypto), clock (Clock), client (HttpClient), and transactions (ExpiringStore<LoginTransaction>) from dependency injection.
It can call Crypto.random, Clock.now, ExpiringStore<LoginTransaction>.put, Crypto.sha256, and HttpClient.request. The handler responds with HTTP 502 for SessionError, HTTP 503 for CryptoError, HTTP 503 for TimeError, and HTTP 503 for StoreFull. It can also raise HttpError.
It gets config from settings. It gets document from discover using injected client. It sets browser, state, nonce, and verifier separately, each to the URL-safe base64 encoding of crypto.random with size 32. It sets transaction to a LoginTransaction with state, nonce, verifier, and expires from clock.now plus 300. source
It calls transactions.put with key from browser, value from transaction, transaction.expires, and now from clock.now. It sets challenge to the URL-safe base64 encoding of crypto.sha256 with input from the UTF-8 bytes of verifier. It builds location as the text {document.authorization_endpoint}?response_type=code&client_id={urlEncode with input from config.clientId}&redirect_uri={urlEncode with input from config.callback}&scope=openid%20profile&state={urlEncode with input from state}&nonce={urlEncode with input from nonce}&code_challenge={urlEncode with input from challenge}&code_challenge_method=S256. It sets headers to withCookie with headers from securityHeaders with the header "location" set to location, name "aug_login", value from browser, path "/login", maxAge 300, and secure from config.secureCookies. source
It returns HTTP 303 with a paragraph containing Opening the identity provider. with escaped text and headers headers. source
Checked interface
startLogin(resolve Crypto crypto, resolve Clock clock, resolve HttpClient client, resolve ExpiringStore<LoginTransaction> transactions) returns HttpResponse<Html> unless CryptoError and HttpError and SessionError and StoreFull and TimeError uses Crypto.random, Clock.now, ExpiringStore<LoginTransaction>.put, Crypto.sha256, HttpClient.requestIt gets crypto (Crypto), clock (Clock), client (HttpClient), and transactions (ExpiringStore<LoginTransaction>) from dependency injection. It can call Crypto.random, Clock.now, ExpiringStore<LoginTransaction>.put, Crypto.sha256, and HttpClient.request. The handler responds with HTTP 502 for SessionError, HTTP 503 for CryptoError, HTTP 503 for TimeError, and HTTP 503 for StoreFull. It can also raise HttpError.
loginCallback · source
loginCallback handles GET /login/callback. Exchange a one-use code over HTTP, verify the provider JWT/JWKS and UserInfo subject, then issue a distinct app-session JWT.
It takes code and state as strings from the HTTP query and browser as optional string from the HTTP cookie aug_login. It gets crypto (Crypto), clock (Clock), client (HttpClient), keys (SigningKeys), transactions (ExpiringStore<LoginTransaction>), and sessions (ExpiringStore<SessionClaims>) from dependency injection. Omitted optional inputs are null. It can call Clock.now, ExpiringStore<LoginTransaction>.take, Crypto.equal, HttpClient.request, Crypto.random, SigningKeys.session, ExpiringStore<SessionClaims>.put, Crypto.signRsa, Crypto.decodeBase64url, Crypto.importRsa, and Crypto.verifyRsa.
The handler responds with HTTP 400 for SessionError, HTTP 503 for CryptoError, HTTP 503 for TimeError, and HTTP 503 for StoreFull. It can also raise HttpError, JsonError, JwtError, and KeyError.
It checks that code is a URL-safe ASCII token with 43 to 43 characters and state is a URL-safe ASCII token with 43 to 43 characters. It raises a SessionError at the first failed check. If browser is null, it raises a SessionError. The non-null browser becomes secret. source
It obtains transactions.take with key from secret and now from clock.now. If no value is found, it raises a SessionError. The non-null result becomes transaction. source
It checks that crypto.equal with left from the UTF-8 bytes of transaction.state and right from the UTF-8 bytes of state returns true. It raises a SessionError at the first failed check. It gets config from settings. It gets document from discover using injected client. source
It builds body as the text grant_type=authorization_code&code={urlEncode with input from code}&redirect_uri={urlEncode with input from config.callback}&client_id={urlEncode with input from config.clientId}&code_verifier={urlEncode with input from transaction.verifier}. It sets headers to a Headers with the header "content-type" set to "application/x-www-form-urlencoded". It sets tokens to decode on responseJson with response from client.request with method "POST", url from document.token_endpoint, headers, and body from the UTF-8 bytes of body for TokenResponse. source
It checks that tokens.token_type equals "Bearer" and tokens.access_token is a URL-safe ASCII token with 43 to 43 characters and tokens.expires_in is greater than 0. It raises a SessionError at the first failed check. It sets jwks to decode on responseJson with response from client.request with method "GET" and url from document.jwks_uri for RsaJwks. It sets identity to validateIdentity with token from tokens.id_token, transaction.nonce, now from clock.now, and jwks using injected crypto. source
It sets authHeaders to a Headers with the header "authorization" set to the text Bearer {tokens.access_token}. It sets user to decode on responseJson with response from client.request with method "GET", url from document.userinfo_endpoint, and headers from authHeaders for UserInfo. It checks that user.sub equals identity.sub. It raises a SessionError at the first failed check. source
It sets now to clock.now. It sets session to a SessionClaims with iss from the text {config.baseUrl}/app, identity.sub, aud "august-app", exp from now plus config.sessionSeconds, iat from now, jti from the URL-safe base64 encoding of crypto.random with size 32, csrf from the URL-safe base64 encoding of crypto.random with size 32, and user.name. It sets jwt to signJwt with key from keys.session, claims from a Json with value from session, kid "session-1", and tokenType "august-session+jwt" using injected crypto. It calls sessions.put with key from session.jti, value from session, expires from session.exp, and now. source
It sets responseHeaders to withCookie with headers from securityHeaders with the header "location" set to "/", name "aug_session", value from jwt, path "/", maxAge from config.sessionSeconds, and secure from config.secureCookies. It sets responseHeaders to withCookie with headers from responseHeaders, name "aug_login", value "", path "/login", maxAge 0, and secure from config.secureCookies. It returns HTTP 303 with a paragraph containing Signed in. with escaped text and responseHeaders headers. source
Checked interface
loginCallback(string code, string state, optional string browser, resolve Crypto crypto, resolve Clock clock, resolve HttpClient client, resolve SigningKeys keys, resolve ExpiringStore<LoginTransaction> transactions, resolve ExpiringStore<SessionClaims> sessions) returns HttpResponse<Html> unless CryptoError and HttpError and JsonError and JwtError and KeyError and SessionError and StoreFull and TimeError uses Clock.now, ExpiringStore<LoginTransaction>.take, Crypto.equal, HttpClient.request, Crypto.random, SigningKeys.session, ExpiringStore<SessionClaims>.put, Crypto.signRsa, Crypto.decodeBase64url, Crypto.importRsa, Crypto.verifyRsaIt takes code and state as strings from the HTTP query and browser as optional string from the HTTP cookie aug_login. It gets crypto (Crypto), clock (Clock), client (HttpClient), keys (SigningKeys), transactions (ExpiringStore<LoginTransaction>), and sessions (ExpiringStore<SessionClaims>) from dependency injection. Omitted optional inputs are null. It can call Clock.now, ExpiringStore<LoginTransaction>.take, Crypto.equal, HttpClient.request, Crypto.random, SigningKeys.session, ExpiringStore<SessionClaims>.put, Crypto.signRsa, Crypto.decodeBase64url, Crypto.importRsa, and Crypto.verifyRsa.
The handler responds with HTTP 400 for SessionError, HTTP 503 for CryptoError, HTTP 503 for TimeError, and HTTP 503 for StoreFull. It can also raise HttpError, JsonError, JwtError, and KeyError.
Dependencies
It uses ExpiringStore (put and take) and StoreFull from memory. It uses HttpClient (request) and urlEncode from web. It uses Crypto (decodeBase64url, equal, importRsa, random, sha256, signRsa, and verifyRsa), JwtError, RsaJwks, and signJwt from crypto. It uses Clock (now) from time.
It uses LoginTransaction (expires, nonce, state, and verifier), SessionClaims (exp and jti), and SessionError from contracts. It uses discover, responseJson, and validateIdentity from protocol. It uses securityHeaders, withCookie, KeyError, SigningKeys (session), and settings from common. It uses Settings (baseUrl, callback, clientId, secureCookies, and sessionSeconds), IdClaims (sub), and Discovery (authorization_endpoint, jwks_uri, token_endpoint, and userinfo_endpoint).
It uses TokenResponse (access_token, expires_in, id_token, and token_type) and UserInfo (name and sub) from provider.
Built-in operations follow the language reference.