-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implementation of OAuth flow in IWAs (#6)
* Implementation of OAuth flow in IWAs * Added the license at the top of oauth.mjs * Incorporated PR feedback. * Implemented PR feedback: - async #readStream async - socket server listening for 127.0.0.1 * Completed the migration to Typescript. * Removed the remaining .then chains. * readStream fetches all the data before returning. --------- Co-authored-by: Sam Richard <[email protected]>
- Loading branch information
Showing
5 changed files
with
383 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
<!doctype html> | ||
<!-- | ||
Copyright 2024 Google LLC | ||
Licensed under the Apache License, Version 2.0 (the "License"); | ||
you may not use this file except in compliance with the License. | ||
You may obtain a copy of the License at | ||
http://www.apache.org/licenses/LICENSE-2.0 | ||
Unless required by applicable law or agreed to in writing, software | ||
distributed under the License is distributed on an "AS IS" BASIS, | ||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
See the License for the specific language governing permissions and | ||
limitations under the License. | ||
--> | ||
|
||
<html lang="en"> | ||
<head> | ||
<meta charset="UTF-8" /> | ||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
<link rel="stylesheet" href="/src/style.css" /> | ||
<title>IWA Kitchen Sink - OAuth flow</title> | ||
<script type="module" src="/src/oauth.ts"></script> | ||
</head> | ||
<body> | ||
<div class="container"> | ||
<!-- Load shared nav --> | ||
<load src="src/components/nav.html" active="Controlled Frame" /> | ||
|
||
<form id="oauth-form"> | ||
<div class="form-control"> | ||
<label for="auth-endpoint">Authentication endpoint</label> | ||
<input type="text" id="auth-endpoint" /> | ||
</div> | ||
<div class="form-control"> | ||
<label for="access-endpoint">Access endpoint</label> | ||
<input type="text" id="access-endpoint" /> | ||
</div> | ||
<div class="form-control"> | ||
<label for="client-id">Client ID</label> | ||
<input type="text" id="client-id" /> | ||
</div> | ||
<div class="form-control"> | ||
<label for="client-secret">Client Secret</label> | ||
<input type="text" id="client-secret" /> | ||
</div> | ||
<div class="form-control"> | ||
<label for="scope">Scope</label> | ||
<input type="text" id="scope" /> | ||
</div> | ||
<div class="fordm-control"> | ||
<button id="oauth-flow">OAuth flow</button> | ||
</div> | ||
<p></p> | ||
<div class="form-control"> | ||
Authentication token | ||
<div id="auth-token"></div> | ||
</div> | ||
<p></p> | ||
<div class="form-control"> | ||
Access code | ||
<div id="access-code"></div> | ||
</div> | ||
</form> | ||
</div> | ||
</body> | ||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,257 @@ | ||
/** | ||
* Copyright 2024 Google LLC | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
export class OAuthConnector { | ||
#redirectUri: string; | ||
#clientId: string; | ||
#clientSecret: string; | ||
#oauthScopes: Array<string>; | ||
#user_message: string; | ||
#socketServer: TCPServerSocket; | ||
#authenticationCode: string | undefined; | ||
#accessCode: string | undefined; | ||
#refreshCode: string | undefined; | ||
#promiseResolver: Promise<string> | undefined; | ||
#promiseRejecter: Promise<void> | undefined; | ||
|
||
#authenticationEndpoint: string; | ||
#tokenEndpoint: string; | ||
|
||
constructor( | ||
authentication_endpoint: string, | ||
token_endpoint: string, | ||
user_message: string = 'You can close this page', | ||
) { | ||
this.#authenticationEndpoint = authentication_endpoint; | ||
this.#tokenEndpoint = token_endpoint; | ||
this.#user_message = user_message; | ||
|
||
this.#socketServer = new TCPServerSocket('127.0.0.1'); | ||
|
||
this.#redirectUri = ''; | ||
this.#clientId = ''; | ||
this.#clientSecret = ''; | ||
this.#oauthScopes = ['']; | ||
} | ||
|
||
getOAuthAccessCode( | ||
client_id: string, | ||
client_secret: string, | ||
scopes: Array<string>, | ||
) { | ||
this.#clientId = client_id; | ||
this.#clientSecret = client_secret; | ||
this.#oauthScopes = scopes; | ||
|
||
const { promise, resolve, reject } = Promise.withResolvers(); | ||
this.#promiseResolver = resolve; | ||
this.#promiseRejecter = reject; | ||
|
||
this.#socketServer.opened.then((server) => { | ||
this.#redirectUri = `http://localhost:${server.localPort}`; | ||
this.#runHttpServer(); | ||
this.#authenticate(this.#authenticationEndpoint); | ||
}); | ||
|
||
return promise; | ||
} | ||
|
||
get authentication_token(): string | undefined { | ||
return this.#authenticationCode; | ||
} | ||
|
||
get access_token(): string | undefined { | ||
return this.#accessCode; | ||
} | ||
|
||
get refresh_token(): string | undefined { | ||
return this.#refreshCode; | ||
} | ||
|
||
#authenticate(endpoint: string): void { | ||
// Create <form> element to submit parameters to OAuth 2.0 endpoint. | ||
const form = document.createElement('form'); | ||
form.setAttribute('method', 'GET'); // Send as a GET request. | ||
form.setAttribute('action', endpoint); | ||
form.setAttribute('target', '_blank'); | ||
|
||
// Parameters to pass to OAuth 2.0 endpoint. | ||
const params = new Map<string, string>([ | ||
['client_id', this.#clientId], | ||
['redirect_uri', this.#redirectUri], | ||
['response_type', 'code'], | ||
['scope', this.#oauthScopes.join(' ')], | ||
['include_granted_scopes', 'true'], | ||
['state', 'pass-through value'], | ||
]); | ||
|
||
// Add form parameters as hidden input values. | ||
params.forEach((value, key) => { | ||
const input = document.createElement('input'); | ||
input.setAttribute('type', 'hidden'); | ||
input.setAttribute('name', key); | ||
input.setAttribute('value', value); | ||
form.appendChild(input); | ||
}); | ||
|
||
// Add form to page and submit it to open the OAuth 2.0 endpoint. | ||
document.body.appendChild(form); | ||
form.submit(); | ||
} | ||
|
||
async #runHttpServer(): Promise<void> { | ||
const server = await this.#socketServer.opened; | ||
const connections = server.readable.getReader(); | ||
|
||
while (true) { | ||
const { value: connection, done } = await connections.read(); | ||
|
||
// Send the connection to the callback | ||
if (connection) { | ||
this.#connectionReceived(connection); | ||
} | ||
|
||
// Release the connection if we're done | ||
if (done) { | ||
connections.releaseLock(); | ||
break; | ||
} | ||
} | ||
|
||
// Wait for the server to be closed | ||
await this.#socketServer.closed; | ||
} | ||
|
||
async #connectionReceived(connection: TCPSocket): Promise<void> { | ||
const socket = await connection.opened; | ||
|
||
const value = await this.#readStream(socket); | ||
if (value == undefined) { | ||
return; | ||
} | ||
|
||
const { success, html } = this.#generateResponse(value); | ||
await this.#writeStream(socket, html); | ||
|
||
connection.close(); | ||
|
||
if (success) { | ||
this.#accessCode = await this.#getAccessCode(); | ||
if (this.#promiseResolver != undefined) { | ||
this.#promiseResolver(this.#accessCode); | ||
} | ||
} | ||
} | ||
|
||
#generateResponse(value: string): { | ||
success: boolean; | ||
html: string; | ||
} { | ||
const text = value; | ||
|
||
if (this.#processRequest(text)) { | ||
return { | ||
success: true, | ||
html: `HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<h1>${this.#user_message}</h1>`, | ||
}; | ||
} else { | ||
return { success: false, html: 'HTTP/1.1 400 KO' }; | ||
} | ||
} | ||
|
||
#processRequest(text: string): boolean { | ||
const lines = text.split('\r\n'); | ||
const params = lines[0].split(' ')[1].substring(2); | ||
const urlParams = new URLSearchParams(params); | ||
const state = urlParams.get('state'); | ||
|
||
if (state != null && state == 'pass-through value') { | ||
const code = urlParams.get('code'); | ||
if (code == null) { | ||
return false; | ||
} | ||
|
||
this.#authenticationCode = code; | ||
return true; | ||
} else { | ||
return false; | ||
} | ||
} | ||
|
||
async #getAccessCode(): Promise<string | undefined> { | ||
// See https://developers.google.com/identity/protocols/oauth2/native-appexchange-authorization-code | ||
|
||
const url_params = | ||
`?code=${this.#authenticationCode}&` + | ||
`client_id=${this.#clientId}&` + | ||
`client_secret=${this.#clientSecret}&` + | ||
`redirect_uri=${this.#redirectUri}&` + | ||
`grant_type=authorization_code`; | ||
|
||
const url = this.#tokenEndpoint + url_params; | ||
|
||
const response = await fetch(url, { | ||
method: 'POST', | ||
headers: { | ||
'Content-Type': 'application/x-www-form-urlencoded', | ||
}, | ||
}); | ||
|
||
if (response.status >= 200 && response.status < 300) { | ||
const json = await response.json(); | ||
this.#accessCode = json.access_token; | ||
this.#refreshCode = json.refresh_token; | ||
|
||
return this.#accessCode; | ||
} else { | ||
return Promise.reject( | ||
new Error(response.status.toString + ' - ' + response.statusText), | ||
); | ||
} | ||
} | ||
|
||
async #writeStream(socket: TCPSocketOpenInfo, text: string): Promise<void> { | ||
const writer = socket.writable.getWriter(); | ||
const encoder = new TextEncoder(); | ||
|
||
const msg = encoder.encode(text + '\r\n'); | ||
|
||
await writer.ready; | ||
writer.write(msg); | ||
writer.releaseLock(); | ||
} | ||
|
||
async #readStream(socket: TCPSocketOpenInfo): Promise<string | undefined> { | ||
const reader = socket.readable | ||
.pipeThrough(new TextDecoderStream()) | ||
.getReader(); | ||
|
||
let result = ''; | ||
while (reader) { | ||
const { value, done } = await reader.read(); | ||
if (value) { | ||
result += value; | ||
} | ||
|
||
if (result.includes('\r\n\r\n') || done) { | ||
reader.releaseLock(); | ||
break; | ||
} | ||
} | ||
|
||
return result; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
/** | ||
* Copyright 2024 Google LLC | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
import { OAuthConnector } from '/src/components/scripts/oauth-connector.ts'; | ||
|
||
const form = document.getElementById('oauth-form') as HTMLFormElement; | ||
const auth_endpoint = document.getElementById( | ||
'auth-endpoint', | ||
) as HTMLInputElement; | ||
const access_endpoint = document.getElementById( | ||
'access-endpoint', | ||
) as HTMLInputElement; | ||
const client_id = document.getElementById('client-id') as HTMLInputElement; | ||
const client_secret = document.getElementById( | ||
'client-secret', | ||
) as HTMLInputElement; | ||
const scope = document.getElementById('scope') as HTMLInputElement; | ||
const access_code = document.getElementById('access-code') as HTMLElement; | ||
const auth_token = document.getElementById('auth-token') as HTMLElement; | ||
|
||
form.addEventListener('submit', async (event) => { | ||
event.preventDefault(); | ||
|
||
const connector = new OAuthConnector( | ||
auth_endpoint.value, | ||
access_endpoint.value, | ||
'You can close this page', | ||
); | ||
|
||
const code: string = await connector.getOAuthAccessCode( | ||
client_id.value, | ||
client_secret.value, | ||
[scope.value], | ||
); | ||
|
||
access_code.innerText = code; | ||
auth_token.innerText = connector.authentication_token; | ||
|
||
console.log('code returned: ' + code); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters